Compare commits
68 Commits
2aab70aec5
...
refactor-n
| Author | SHA1 | Date | |
|---|---|---|---|
| 123c006a1e | |||
| 415811f592 | |||
| 31066a4d8f | |||
| b700c2de8d | |||
| fda5b8fe2e | |||
| b7a525056a | |||
| 7a11e829b3 | |||
| 361b61a694 | |||
| 0cb060c89c | |||
| 4b9cbf65f9 | |||
| 813fc9dd38 | |||
| f574d6197b | |||
| 2fac3117a4 | |||
| 7b072a219d | |||
| 23d3356e6b | |||
| 5b2702daed | |||
| b9c3eb7091 | |||
| 6fbc5d947c | |||
| f520a02226 | |||
| 3a43b2672a | |||
| 61959a5a2e | |||
| 5f16ee2b38 | |||
| a8ebb1dada | |||
| 862719b8b0 | |||
| d1ee6d8a10 | |||
| c3268012a5 | |||
| da24b6a5ed | |||
| 8b8dd0a427 | |||
| 979ab5e86d | |||
| 9703cb5ce8 | |||
| c85f4b086e | |||
| f190ad9353 | |||
| 659963beb0 | |||
| d3b1e52d88 | |||
| 3c0880f527 | |||
| 8a1b582f4e | |||
| 364474471c | |||
| 3ecf617998 | |||
| 3f2f55d6c2 | |||
| 4e03d52a5d | |||
| 2bdba523ad | |||
| 716de36bfa | |||
| 00d5890a37 | |||
| ecb161bc07 | |||
| 1ee4a3bf45 | |||
| 5e99324201 | |||
| b06a655bc3 | |||
| 906265a0e3 | |||
| 1a21b44bc8 | |||
| a8c9e0f253 | |||
| 491a857f61 | |||
| b3f463b688 | |||
| 9a5d0e33bd | |||
| a166992b04 | |||
| b5ccb0428d | |||
| f4a8314978 | |||
| f19f19a279 | |||
| ad35f641b3 | |||
| 6c892bf580 | |||
| 89099c2cfd | |||
| 0f9616f19a | |||
| 3b3cfb5e43 | |||
| 24004a99da | |||
| ab7601a74e | |||
| f09606e1f7 | |||
| c610d68b9c | |||
| efb82b0d4a | |||
| 216fd85888 |
90
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
name: Build and Release FLUX (Multi-Platform)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# JOB 1: WINDOWS (Gira sul PC del collega appena si libera)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-native
|
||||||
|
steps:
|
||||||
|
- name: Checkout del codice
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Crea file .env
|
||||||
|
run: |
|
||||||
|
Set-Content -Path ".env" -Value "${{ secrets.ENV_FILE_CONTENT }}"
|
||||||
|
|
||||||
|
- name: Build Flutter Windows
|
||||||
|
run: flutter build windows --release
|
||||||
|
|
||||||
|
- name: Build Windows Installer
|
||||||
|
run: |
|
||||||
|
$TagVersion = "${{ github.ref_name }}".Substring(1)
|
||||||
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
|
||||||
|
|
||||||
|
# Nel dubbio usiamo l'action per caricare l'asset
|
||||||
|
- name: Upload Windows Asset
|
||||||
|
uses: https://gitea.com/actions/release-action@main
|
||||||
|
with:
|
||||||
|
files: "build/windows/installer/FluxInstaller.exe"
|
||||||
|
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Pulisci Workspace Windows
|
||||||
|
if: always()
|
||||||
|
run: Remove-Item -Recurse -Force ./*
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# JOB 2: ANDROID APK (Gira sul tuo MacBook)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
build-android:
|
||||||
|
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||||
|
steps:
|
||||||
|
- name: Checkout del codice
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Logica Bash per Mac: usiamo le virgolette singole forti per evitare escape strani
|
||||||
|
- name: Crea file .env
|
||||||
|
run: |
|
||||||
|
cat << 'EOF' > .env
|
||||||
|
${{ secrets.ENV_FILE_CONTENT }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build Flutter APK
|
||||||
|
run: flutter build apk --release
|
||||||
|
|
||||||
|
# Carichiamo l'APK universale o quelli splittati nelle release di Gitea
|
||||||
|
- name: Upload Android Asset
|
||||||
|
uses: https://gitea.com/actions/release-action@main
|
||||||
|
with:
|
||||||
|
files: "build/app/outputs/flutter-apk/app-release.apk"
|
||||||
|
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
build-web:
|
||||||
|
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||||
|
steps:
|
||||||
|
- name: Checkout del codice
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Crea file .env
|
||||||
|
run: |
|
||||||
|
cat << 'EOF' > .env
|
||||||
|
${{ secrets.ENV_FILE_CONTENT }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build Flutter Web
|
||||||
|
run: flutter build web --release
|
||||||
|
|
||||||
|
# Sfruttiamo npx (incluso in Node.js) per lanciare wrangler al volo senza installarlo globalmente
|
||||||
|
# Sto assumendo che usi Cloudflare Pages che è perfetto per Flutter Web statico
|
||||||
|
- name: Deploy su Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx wrangler pages deploy build/web --project-name="flux" --branch="main"
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<application
|
<application
|
||||||
android:label="flux"
|
android:label="flux"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
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
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
BIN
assets/icon/icon.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
34
flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# flutter pub run flutter_launcher_icons
|
||||||
|
flutter_launcher_icons:
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
|
|
||||||
|
android: "launcher_icon"
|
||||||
|
image_path_android: "assets/icon/icon.png"
|
||||||
|
min_sdk_android: 21 # android min sdk min:16, default 21
|
||||||
|
# adaptive_icon_background: "assets/icon/background.png"
|
||||||
|
# adaptive_icon_foreground: "assets/icon/foreground.png"
|
||||||
|
# adaptive_icon_foreground_inset: 16
|
||||||
|
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
|
||||||
|
|
||||||
|
ios: true
|
||||||
|
image_path_ios: "assets/icon/icon.png"
|
||||||
|
remove_alpha_ios: true
|
||||||
|
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
|
||||||
|
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
|
||||||
|
# desaturate_tinted_to_grayscale_ios: true
|
||||||
|
background_color_ios: "#ffffff"
|
||||||
|
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
|
background_color: "#FFFFFF"
|
||||||
|
theme_color: "#000000"
|
||||||
|
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
|
icon_size: 256 # min:48, max:256, default: 48
|
||||||
|
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -600,7 +600,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
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 |
@@ -121,6 +121,8 @@ class SessionCubit extends Cubit<SessionState> {
|
|||||||
await _prefs.setString(_lastStoreKey, activeStore.id!);
|
await _prefs.setString(_lastStoreKey, activeStore.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
|
||||||
|
|
||||||
// 4. BENVENUTO A BORDO
|
// 4. BENVENUTO A BORDO
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -164,4 +166,8 @@ class SessionCubit extends Cubit<SessionState> {
|
|||||||
void setIsMobileDevice(bool isMobile) {
|
void setIsMobileDevice(bool isMobile) {
|
||||||
emit(state.copyWith(isMobileDevice: isMobile));
|
emit(state.copyWith(isMobileDevice: isMobile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setIsSingleUserMode(bool isSingleUser) {
|
||||||
|
emit(state.copyWith(isSingleUserMode: isSingleUser));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SessionState extends Equatable {
|
|||||||
final StaffMemberModel? currentStaffMember;
|
final StaffMemberModel? currentStaffMember;
|
||||||
final OnboardingStep onboardingStep;
|
final OnboardingStep onboardingStep;
|
||||||
final bool isMobileDevice;
|
final bool isMobileDevice;
|
||||||
|
final bool isSingleUserMode;
|
||||||
|
|
||||||
const SessionState({
|
const SessionState({
|
||||||
this.status = SessionStatus.initial,
|
this.status = SessionStatus.initial,
|
||||||
@@ -34,6 +35,7 @@ class SessionState extends Equatable {
|
|||||||
this.currentStaffMember,
|
this.currentStaffMember,
|
||||||
this.onboardingStep = OnboardingStep.none,
|
this.onboardingStep = OnboardingStep.none,
|
||||||
this.isMobileDevice = false,
|
this.isMobileDevice = false,
|
||||||
|
this.isSingleUserMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
||||||
@@ -45,6 +47,7 @@ class SessionState extends Equatable {
|
|||||||
StaffMemberModel? currentStaffMember,
|
StaffMemberModel? currentStaffMember,
|
||||||
OnboardingStep? onboardingStep,
|
OnboardingStep? onboardingStep,
|
||||||
bool? isMobileDevice,
|
bool? isMobileDevice,
|
||||||
|
bool? isSingleUserMode,
|
||||||
}) {
|
}) {
|
||||||
return SessionState(
|
return SessionState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -54,6 +57,7 @@ class SessionState extends Equatable {
|
|||||||
currentStaffMember: currentStaffMember ?? this.currentStaffMember,
|
currentStaffMember: currentStaffMember ?? this.currentStaffMember,
|
||||||
onboardingStep: onboardingStep ?? this.onboardingStep,
|
onboardingStep: onboardingStep ?? this.onboardingStep,
|
||||||
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
||||||
|
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ class SessionState extends Equatable {
|
|||||||
currentStaffMember,
|
currentStaffMember,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
isMobileDevice,
|
isMobileDevice,
|
||||||
|
isSingleUserMode,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper rapidi per la UI
|
// 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:flutter/foundation.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/company/models/company_model.dart';
|
import 'package:flux/features/company/models/company_model.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
@@ -15,7 +16,7 @@ class CoreRepository {
|
|||||||
Future<CompanyModel?> getCompanyByOwnerId(String userId) async {
|
Future<CompanyModel?> getCompanyByOwnerId(String userId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.select()
|
.select()
|
||||||
.eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB!
|
.eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB!
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -31,7 +32,7 @@ class CoreRepository {
|
|||||||
Future<CompanyModel?> getCompanyById(String companyId) async {
|
Future<CompanyModel?> getCompanyById(String companyId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.select()
|
.select()
|
||||||
.eq('id', companyId)
|
.eq('id', companyId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -46,7 +47,7 @@ class CoreRepository {
|
|||||||
Future<List<StoreModel>> getStoresByCompanyId(String companyId) async {
|
Future<List<StoreModel>> getStoresByCompanyId(String companyId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('store')
|
.from(Tables.stores)
|
||||||
.select()
|
.select()
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.eq('is_active', true) // Buona pratica
|
.eq('is_active', true) // Buona pratica
|
||||||
@@ -62,7 +63,7 @@ class CoreRepository {
|
|||||||
Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async {
|
Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_member')
|
.from(Tables.staffMembers)
|
||||||
.select()
|
.select()
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -80,7 +81,7 @@ class CoreRepository {
|
|||||||
Future<CompanyModel> createCompany(CompanyModel company) async {
|
Future<CompanyModel> createCompany(CompanyModel company) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.insert(company.toMap())
|
.insert(company.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -94,7 +95,7 @@ class CoreRepository {
|
|||||||
Future<StoreModel> createStore(StoreModel store) async {
|
Future<StoreModel> createStore(StoreModel store) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('store')
|
.from(Tables.stores)
|
||||||
.insert(store.toMap())
|
.insert(store.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -108,12 +109,12 @@ class CoreRepository {
|
|||||||
Future<StaffMemberModel> createStaffMember(StaffMemberModel staff) async {
|
Future<StaffMemberModel> createStaffMember(StaffMemberModel staff) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_member')
|
.from(Tables.staffMembers)
|
||||||
.insert(staff.toMap())
|
.insert(staff.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
final StaffMemberModel staffMember = StaffMemberModel.fromMap(response);
|
final StaffMemberModel staffMember = StaffMemberModel.fromMap(response);
|
||||||
await _supabase.from('staff_in_stores').insert({
|
await _supabase.from(Tables.staffInStores).insert({
|
||||||
'staff_member_id': staffMember.id,
|
'staff_member_id': staffMember.id,
|
||||||
'store_id': GetIt.I.get<SessionCubit>().state.currentStore!.id,
|
'store_id': GetIt.I.get<SessionCubit>().state.currentStore!.id,
|
||||||
});
|
});
|
||||||
@@ -126,7 +127,7 @@ class CoreRepository {
|
|||||||
|
|
||||||
// Assegna un membro a un negozio
|
// Assegna un membro a un negozio
|
||||||
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
||||||
await _supabase.from('staff_in_stores').insert({
|
await _supabase.from(Tables.staffInStores).insert({
|
||||||
'staff_member_id': staffId,
|
'staff_member_id': staffId,
|
||||||
'store_id': storeId,
|
'store_id': storeId,
|
||||||
});
|
});
|
||||||
@@ -136,6 +137,6 @@ class CoreRepository {
|
|||||||
String staffId,
|
String staffId,
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
) async {
|
) async {
|
||||||
await _supabase.from('staff_member').update(data).eq('id', staffId);
|
await _supabase.from(Tables.staffMembers).update(data).eq('id', staffId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
lib/core/enums_and_consts/consts.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
class Tables {
|
||||||
|
static const String attachments = 'attachments';
|
||||||
|
static const String brands = 'brands';
|
||||||
|
static const String campaigns = 'campaigns';
|
||||||
|
static const String companies = 'companies';
|
||||||
|
static const String customers = 'customers';
|
||||||
|
static const String documentSequences = 'document_sequences';
|
||||||
|
static const String models = 'models';
|
||||||
|
static const String notes = 'notes';
|
||||||
|
static const String noteCollaborators = 'note_collaborators';
|
||||||
|
static const String operations = 'operations';
|
||||||
|
static const String providerLocations = 'provider_locations';
|
||||||
|
static const String providers = 'providers';
|
||||||
|
static const String providersInStores = 'providers_in_stores';
|
||||||
|
static const String shippingDocuments = 'shipping_documents';
|
||||||
|
static const String staffInStores = 'staff_in_stores';
|
||||||
|
static const String staffMembers = 'staff_members';
|
||||||
|
static const String stores = 'stores';
|
||||||
|
static const String tickets = 'tickets';
|
||||||
|
static const String trackings = 'trackings';
|
||||||
|
}
|
||||||
|
|
||||||
|
const String resetPasswordUrl =
|
||||||
|
'https://flux-web-invite.marco-6ba.workers.dev/';
|
||||||
@@ -1,97 +1,370 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. IL GUSCIO (QUELLO CHE PASSI AL ROUTER)
|
||||||
|
// ==========================================
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const AppShell({super.key, required this.child});
|
const AppShell({super.key, required this.child});
|
||||||
|
|
||||||
// Calcoliamo l'indice attivo in base all'URL corrente!
|
|
||||||
int _calculateSelectedIndex(BuildContext context) {
|
|
||||||
final String location = GoRouterState.of(context).uri.path;
|
|
||||||
if (location.startsWith('/master-data')) return 1;
|
|
||||||
if (location.startsWith('/settings')) return 2;
|
|
||||||
return 0; // Default: Dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onItemTapped(int index, BuildContext context) {
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
context.go('/');
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
context.go('/master-data');
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
context.go('/settings');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentIndex = _calculateSelectedIndex(context);
|
// Breakpoint a 900px: sotto è Mobile/Tablet (Drawer), sopra è Desktop (Sidebar)
|
||||||
// Breakpoint: se lo schermo è più largo di 600px, usiamo la Rail laterale
|
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
|
||||||
final isDesktop = MediaQuery.sizeOf(context).width >= 600;
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
// Su mobile usiamo un'AppBar minimale per avere il bottone "Hamburger" nativo
|
||||||
|
appBar: isDesktop
|
||||||
|
? null
|
||||||
|
: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
"FLUX",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
drawer: isDesktop
|
||||||
|
? null
|
||||||
|
: Drawer(
|
||||||
|
// Su mobile inietta il menu qui!
|
||||||
|
child: AppMenu(currentPath: currentPath, isDrawer: true),
|
||||||
|
),
|
||||||
body: isDesktop
|
body: isDesktop
|
||||||
? Row(
|
? Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
// Su desktop inietta il menu a sinistra!
|
||||||
selectedIndex: currentIndex,
|
AppMenu(currentPath: currentPath, isDrawer: false),
|
||||||
onDestinationSelected: (index) =>
|
|
||||||
_onItemTapped(index, context),
|
|
||||||
labelType: NavigationRailLabelType.all,
|
|
||||||
destinations: [
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
|
||||||
label: Text(context.l10n.commonDashboard),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.folder_special_outlined),
|
|
||||||
selectedIcon: Icon(Icons.folder_special),
|
|
||||||
label: Text(context.l10n.commonMasterData),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
selectedIcon: Icon(Icons.settings),
|
|
||||||
label: Text(context.l10n.commonSettings),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const VerticalDivider(thickness: 1, width: 1),
|
const VerticalDivider(thickness: 1, width: 1),
|
||||||
// Il contenuto della pagina
|
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: child, // Su mobile il contenuto prende tutto lo schermo...
|
: child, // Su mobile il child prende tutto lo schermo sotto l'AppBar
|
||||||
// ... e mettiamo la barra in basso!
|
);
|
||||||
bottomNavigationBar: isDesktop
|
}
|
||||||
? null
|
}
|
||||||
: NavigationBar(
|
|
||||||
selectedIndex: currentIndex,
|
class AppMenu extends StatefulWidget {
|
||||||
onDestinationSelected: (index) => _onItemTapped(index, context),
|
final String currentPath; // Lo usiamo ancora per capire cosa accendere
|
||||||
destinations: [
|
final bool isDrawer;
|
||||||
NavigationDestination(
|
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
const AppMenu({super.key, required this.currentPath, required this.isDrawer});
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
|
||||||
label: context.l10n.commonDashboard,
|
@override
|
||||||
|
State<AppMenu> createState() => _AppMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppMenuState extends State<AppMenu> {
|
||||||
|
bool _isCollapsed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final bool effectivelyCollapsed = _isCollapsed && !widget.isDrawer;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: effectivelyCollapsed ? 72 : 260,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// --- HEADER ---
|
||||||
|
Container(
|
||||||
|
height: 80,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||||
|
alignment: effectivelyCollapsed
|
||||||
|
? Alignment.center
|
||||||
|
: Alignment.centerLeft,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
|
||||||
|
if (!effectivelyCollapsed) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
"FLUX",
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
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: context.l10n.commonDashboard,
|
||||||
|
icon: Icons.dashboard_outlined,
|
||||||
|
routeName: Routes.home, // <--- Usiamo la tua costante!
|
||||||
|
pathToCheck:
|
||||||
|
'/', // Il path da controllare per colorarlo
|
||||||
|
isCollapsed: effectivelyCollapsed,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
|
||||||
|
_buildHierarchicalItem(
|
||||||
|
title: context.l10n.commonMasterData,
|
||||||
|
icon: Icons.folder_special_outlined,
|
||||||
|
basePathToCheck:
|
||||||
|
'/master-data', // Se il path inizia così, espandi
|
||||||
|
isCollapsed: effectivelyCollapsed,
|
||||||
|
subItems: [
|
||||||
|
_SubMenuItem(
|
||||||
|
"Clienti",
|
||||||
|
Routes.customers,
|
||||||
|
'/master-data/customers',
|
||||||
|
),
|
||||||
|
_SubMenuItem(
|
||||||
|
"Fornitori",
|
||||||
|
Routes.providers,
|
||||||
|
'/master-data/providers',
|
||||||
|
),
|
||||||
|
_SubMenuItem(
|
||||||
|
"Prodotti",
|
||||||
|
Routes.products,
|
||||||
|
'/master-data/products',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildRouteItem(
|
||||||
|
title: context.l10n.commonSettings,
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
routeName: Routes.settings,
|
||||||
|
pathToCheck: '/settings',
|
||||||
|
isCollapsed: effectivelyCollapsed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- PULSANTE TOGGLE (Solo Desktop) ---
|
||||||
|
if (!widget.isDrawer)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
|
||||||
|
icon: Icon(
|
||||||
|
_isCollapsed
|
||||||
|
? Icons.keyboard_double_arrow_right
|
||||||
|
: Icons.keyboard_double_arrow_left,
|
||||||
|
color: theme.iconTheme.color?.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isCollapsed = !_isCollapsed;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// WIDGET HELPER AGGIORNATI
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
Widget _buildRouteItem({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required String routeName,
|
||||||
|
required String pathToCheck,
|
||||||
|
required bool isCollapsed,
|
||||||
|
}) {
|
||||||
|
final isSelected = widget.currentPath == pathToCheck;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return Tooltip(
|
||||||
|
message: title,
|
||||||
|
preferBelow: false,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if (widget.isDrawer) Navigator.pop(context);
|
||||||
|
context.goNamed(routeName); // <--- goNamed!
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||||
|
alpha: 0.4,
|
||||||
|
),
|
||||||
|
selected: isSelected,
|
||||||
|
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.isDrawer) Navigator.pop(context);
|
||||||
|
context.goNamed(routeName); // <--- goNamed!
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHierarchicalItem({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required String basePathToCheck,
|
||||||
|
required bool isCollapsed,
|
||||||
|
required List<_SubMenuItem> subItems,
|
||||||
|
}) {
|
||||||
|
final isSelected = widget.currentPath.startsWith(basePathToCheck);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
tooltip: title,
|
||||||
|
offset: const Offset(60, 0),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onSelected: (routeName) {
|
||||||
|
// Il routeName arriva dal value del menu
|
||||||
|
if (widget.isDrawer) Navigator.pop(context);
|
||||||
|
context.goNamed(routeName); // <--- goNamed!
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => subItems
|
||||||
|
.map(
|
||||||
|
(item) => PopupMenuItem(
|
||||||
|
value: item
|
||||||
|
.routeName, // Passiamo il nome della rotta (Routes.customers)
|
||||||
|
child: Text(
|
||||||
|
item.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: widget.currentPath == item.pathToCheck
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: widget.currentPath == item.pathToCheck
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
initiallyExpanded: isSelected,
|
||||||
|
maintainState: true,
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
leading: Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
),
|
||||||
|
children: subItems.map((item) {
|
||||||
|
final isSubSelected = widget.currentPath == item.pathToCheck;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||||
|
alpha: 0.2,
|
||||||
|
),
|
||||||
|
selected: isSubSelected,
|
||||||
|
title: Text(
|
||||||
|
item.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSubSelected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSubSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.textTheme.bodyMedium?.color,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.isDrawer) Navigator.pop(context);
|
||||||
|
context.goNamed(item.routeName); // <--- goNamed!
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Struttura dati per le voci dei sottomenu aggiornata
|
||||||
|
class _SubMenuItem {
|
||||||
|
final String title;
|
||||||
|
final String routeName; // Es: Routes.customers
|
||||||
|
final String pathToCheck; // Es: '/master-data/customers'
|
||||||
|
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,34 +12,43 @@ import 'package:flux/core/widgets/image_upload/ui/upload_success_screen.dart';
|
|||||||
import 'package:flux/features/auth/ui/auth_screen.dart';
|
import 'package:flux/features/auth/ui/auth_screen.dart';
|
||||||
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
|
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
|
||||||
import 'package:flux/features/company/ui/company_settings_screen.dart';
|
import 'package:flux/features/company/ui/company_settings_screen.dart';
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||||
|
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
||||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
import 'package:flux/features/customers/ui/customer_form_screen.dart';
|
||||||
|
import 'package:flux/features/customers/ui/customers_list_screen.dart';
|
||||||
import 'package:flux/features/home/ui/home_screen.dart';
|
import 'package:flux/features/home/ui/home_screen.dart';
|
||||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||||
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
import 'package:flux/features/master_data/providers/blocs/provider_form_cubit.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
|
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||||
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
||||||
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
|
import 'package:flux/features/notes/ui/notes_form_screen.dart';
|
||||||
|
import 'package:flux/features/notes/ui/notes_list_screen.dart';
|
||||||
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
||||||
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
||||||
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
||||||
import 'package:flux/features/settings/settings_view.dart';
|
import 'package:flux/features/settings/settings_screen.dart';
|
||||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||||
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
||||||
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
|
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
|
||||||
|
import 'package:flux/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart';
|
||||||
|
import 'package:flux/features/tracking/blocs/tracking_cubit.dart';
|
||||||
|
import 'package:flux/features/tracking/models/tracking_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -117,94 +126,117 @@ class AppRouter {
|
|||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => AppShell(child: child),
|
builder: (context, state, child) => AppShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
// ==========================================
|
||||||
// 1. DASHBOARD
|
// 1. DASHBOARD
|
||||||
|
// ==========================================
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: Routes.home,
|
name: Routes.home,
|
||||||
builder: (context, state) => const HomeScreen(),
|
builder: (context, state) => const HomeScreen(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
|
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
|
||||||
|
// ==========================================
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/master-data',
|
path: '/master-data',
|
||||||
name: Routes.masterData,
|
name: Routes.masterData,
|
||||||
builder: (context, state) => const MasterDataHubScreen(),
|
builder: (context, state) => const MasterDataHubScreen(),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'products', // Diventa /master-data/products
|
path:
|
||||||
|
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
|
||||||
|
name: Routes.customers,
|
||||||
|
builder: (context, state) => const CustomersListScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'providers', // -> /master-data/providers
|
||||||
|
name: Routes.providers,
|
||||||
|
builder: (context, state) => const ProviderListScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'products', // -> /master-data/products
|
||||||
name: Routes.products,
|
name: Routes.products,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
context.read<ProductsCubit>().refreshCubit();
|
context.read<ProductsCubit>().refreshCubit();
|
||||||
|
|
||||||
return const ProductsScreen();
|
return const ProductsScreen();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'company-settings',
|
path: 'staff', // -> /master-data/staff
|
||||||
|
name: Routes.staff,
|
||||||
|
builder: (context, state) => const StaffScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path:
|
||||||
|
'stores', // Sistemata l'inversione path/name -> /master-data/stores
|
||||||
|
name: Routes.stores,
|
||||||
|
builder: (context, state) => const StoresScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'company-settings', // -> /master-data/company-settings
|
||||||
name: Routes.companySettings,
|
name: Routes.companySettings,
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => BlocProvider(
|
||||||
create: (context) => CompanySettingsCubit(),
|
create: (context) => CompanySettingsCubit(),
|
||||||
child: const CompanySettingsScreen(),
|
child: const CompanySettingsScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: 'staff',
|
|
||||||
name: Routes.staff, // Diventa /master-data/staff
|
|
||||||
builder: (context, state) => const StaffScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: Routes.stores,
|
|
||||||
name: 'stores', // Diventa /master-data/stores
|
|
||||||
builder: (context, state) => const StoresScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: 'providers',
|
|
||||||
name: Routes.providers, // Diventa /master-data/providers
|
|
||||||
builder: (context, state) =>
|
|
||||||
const ProvidersMasterDataScreen(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
// 3. IMPOSTAZIONI
|
// 3. IMPOSTAZIONI
|
||||||
|
// ==========================================
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: Routes.settings,
|
name: Routes.settings,
|
||||||
builder: (context, state) => const SettingsView(),
|
builder: (context, state) => const SettingsScreen(),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'themeSettings',
|
path: 'themeSettings', // -> /settings/themeSettings
|
||||||
name: Routes.themeSettings,
|
name: Routes.themeSettings,
|
||||||
builder: (context, state) => const ThemeSettingsView(),
|
builder: (context, state) => const ThemeSettingsView(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
|
||||||
|
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
|
||||||
|
// ==========================================
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/operations',
|
path: '/operations',
|
||||||
name: Routes.operations,
|
name: Routes.operations,
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => const OperationListScreen(),
|
||||||
create: (context) => OperationListCubit(),
|
|
||||||
child: const OperationListScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/customers',
|
|
||||||
name: Routes.customers,
|
|
||||||
builder: (context, state) =>
|
|
||||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tickets',
|
path: '/tickets',
|
||||||
name: Routes.tickets,
|
name: Routes.tickets,
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => const TicketListScreen(),
|
||||||
create: (context) => TicketListCubit(),
|
|
||||||
child: const TicketListScreen(),
|
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/notes',
|
||||||
|
name: Routes.notes,
|
||||||
|
builder: (context, state) => const NotesListScreen(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||||
|
GoRoute(
|
||||||
|
path: '/providers/form',
|
||||||
|
name: Routes.providerForm,
|
||||||
|
builder: (context, state) {
|
||||||
|
// Estraiamo il fornitore (se stiamo modificando e non creando)
|
||||||
|
final existingProvider = state.extra as ProviderModel?;
|
||||||
|
|
||||||
|
return BlocProvider<ProviderFormCubit>(
|
||||||
|
// Inizializziamo un Cubit NUOVO ogni volta che apriamo il form
|
||||||
|
create: (context) => ProviderFormCubit(),
|
||||||
|
child: ProviderFormScreen(existingProvider: existingProvider),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
|
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
|
||||||
path: '/tickets/form/:id',
|
path: '/tickets/form/:id',
|
||||||
@@ -213,15 +245,29 @@ class AppRouter {
|
|||||||
// 1. Leggiamo l'ID dall'URL
|
// 1. Leggiamo l'ID dall'URL
|
||||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||||
|
|
||||||
// 2. Leggiamo l'oggetto dalla RAM (se arriviamo da un tap interno all'app)
|
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
|
||||||
final TicketModel? ticketFromExtra = state.extra as TicketModel?;
|
final record =
|
||||||
|
state.extra
|
||||||
|
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
|
||||||
|
|
||||||
// 3. Capiamo se è un nuovo ticket o una modifica
|
// 3. LOGICA SOBRIA
|
||||||
final String? realTicketId = pathId == 'new' ? null : pathId;
|
final String? realTicketId;
|
||||||
context.read<StaffCubit>().loadStaffForStore(
|
|
||||||
GetIt.I.get<SessionCubit>().state.currentStore!.id!,
|
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>().loadModels();
|
||||||
context.read<ProductsCubit>().loadBrands();
|
context.read<ProductsCubit>().loadBrands();
|
||||||
|
|
||||||
@@ -233,24 +279,60 @@ class AppRouter {
|
|||||||
parentId: realTicketId,
|
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(
|
child: TicketFormScreen(
|
||||||
ticketId: realTicketId,
|
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(
|
GoRoute(
|
||||||
path: '/upload-success',
|
path: '/upload-success',
|
||||||
name: Routes.uploadSuccess,
|
name: Routes.uploadSuccess,
|
||||||
builder: (context, state) => const UploadSuccessScreen(),
|
builder: (context, state) => const UploadSuccessScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/customer/form/:id',
|
path: '/customer/details/:id',
|
||||||
name: 'customer-form',
|
name: Routes.customerDetails,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final customer = state.extra as CustomerModel;
|
final customer = state.extra as CustomerModel;
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
@@ -262,28 +344,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(
|
GoRoute(
|
||||||
path: '/operations/form/:id',
|
path: '/operations/form/:id',
|
||||||
name: Routes.operationForm,
|
name: Routes.operationForm,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||||
final OperationModel? operationFromExtra =
|
|
||||||
state.extra as OperationModel?;
|
final record =
|
||||||
final String? realOperationId = pathId == 'new' ? null : pathId;
|
state.extra
|
||||||
|
as ({
|
||||||
|
StaffMemberModel? createdBy,
|
||||||
|
OperationModel? operation,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
final String? realOperationId;
|
||||||
|
if (pathId == 'new') {
|
||||||
|
realOperationId = null;
|
||||||
|
} else if (record?.operation?.id != null) {
|
||||||
|
realOperationId = record!.operation!.id;
|
||||||
|
} else {
|
||||||
|
realOperationId = pathId;
|
||||||
|
}
|
||||||
final currentStoreId = GetIt.I
|
final currentStoreId = GetIt.I
|
||||||
.get<SessionCubit>()
|
.get<SessionCubit>()
|
||||||
.state
|
.state
|
||||||
.currentStore!
|
.currentStore!
|
||||||
.id!;
|
.id!;
|
||||||
context.read<CustomersCubit>().loadCustomers();
|
context.read<CustomersListCubit>().loadCustomers();
|
||||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
context.read<ProviderListCubit>().loadProviders(currentStoreId);
|
||||||
currentStoreId,
|
|
||||||
);
|
|
||||||
context.read<ProductsCubit>().loadModels();
|
context.read<ProductsCubit>().loadModels();
|
||||||
context.read<ProductsCubit>().loadBrands();
|
context.read<ProductsCubit>().loadBrands();
|
||||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
|
||||||
|
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
@@ -292,11 +408,16 @@ class AppRouter {
|
|||||||
parentType: AttachmentParentType.operation,
|
parentType: AttachmentParentType.operation,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlocProvider(create: (context) => OperationFormCubit()),
|
BlocProvider(
|
||||||
|
create: (context) => OperationFormCubit(
|
||||||
|
createdBy: record?.createdBy,
|
||||||
|
existingOperation: record?.operation,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: OperationFormScreen(
|
child: OperationFormScreen(
|
||||||
operationId: realOperationId,
|
operationId: realOperationId,
|
||||||
existingOperation: operationFromExtra,
|
existingOperation: record?.operation,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -334,6 +455,28 @@ 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class Routes {
|
|||||||
static const String staff = 'staff';
|
static const String staff = 'staff';
|
||||||
static const String stores = 'stores';
|
static const String stores = 'stores';
|
||||||
static const String providers = 'providers';
|
static const String providers = 'providers';
|
||||||
|
static const String providerForm = 'provider-form';
|
||||||
static const String settings = 'settings';
|
static const String settings = 'settings';
|
||||||
static const String themeSettings = 'themeSettings';
|
static const String themeSettings = 'themeSettings';
|
||||||
static const String operations = 'operations';
|
static const String operations = 'operations';
|
||||||
@@ -18,5 +19,9 @@ class Routes {
|
|||||||
static const String operationForm = 'operation-form';
|
static const String operationForm = 'operation-form';
|
||||||
static const String uploadSuccess = 'upload-success';
|
static const String uploadSuccess = 'upload-success';
|
||||||
static const String customerForm = 'customer-form';
|
static const String customerForm = 'customer-form';
|
||||||
|
static const String customerDetails = 'customer-details';
|
||||||
static const String upload = 'upload';
|
static const String upload = 'upload';
|
||||||
|
static const String ticketWorkspace = 'ticket-workspace';
|
||||||
|
static const String noteForm = 'note-form';
|
||||||
|
static const String notes = 'notes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flux/core/enums/enums.dart';
|
import 'package:flux/core/enums_and_consts/enums.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/core/utils/version_check_service.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
class VersionCheckService {
|
||||||
|
final _supabase = Supabase.instance.client;
|
||||||
|
|
||||||
|
/// Controlla se l'app corrente deve essere bloccata o aggiornata.
|
||||||
|
/// Ritorna il link di download se l'aggiornamento è obbligatorio, altrimenti null.
|
||||||
|
Future<String?> checkForceUpdate() async {
|
||||||
|
try {
|
||||||
|
// 1. Determiniamo la piattaforma corrente
|
||||||
|
String platformKey = 'web';
|
||||||
|
if (!kIsWeb) {
|
||||||
|
if (Platform.isAndroid) platformKey = 'android';
|
||||||
|
if (Platform.isWindows) platformKey = 'windows';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Recuperiamo la configurazione minima da Supabase
|
||||||
|
final data = await _supabase
|
||||||
|
.from('app_config')
|
||||||
|
.select()
|
||||||
|
.eq('platform', platformKey)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (data == null) return null;
|
||||||
|
|
||||||
|
final String minVersion = data['min_version'];
|
||||||
|
final String downloadUrl = data['download_url'];
|
||||||
|
|
||||||
|
// 3. Recuperiamo la versione attuale dell'app dal pubspec.yaml
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final String currentVersion = packageInfo.version;
|
||||||
|
|
||||||
|
// 4. Confronto matematico semantico (es. 1.2.3 vs 1.1.9)
|
||||||
|
if (_isVersionLower(currentVersion, minVersion)) {
|
||||||
|
return downloadUrl; // Aggiornamento obbligatorio richiesto!
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Errore controllo versione: $e');
|
||||||
|
return null; // In caso di errore non blocchiamo l'utente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVersionLower(String current, String min) {
|
||||||
|
List<int> currentParts = current
|
||||||
|
.split('.')
|
||||||
|
.map((e) => int.tryParse(e) ?? 0)
|
||||||
|
.toList();
|
||||||
|
List<int> minParts = min
|
||||||
|
.split('.')
|
||||||
|
.map((e) => int.tryParse(e) ?? 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
int currentPart = currentParts.length > i ? currentParts[i] : 0;
|
||||||
|
int minPart = minParts.length > i ? minParts[i] : 0;
|
||||||
|
|
||||||
|
if (currentPart < minPart) return true;
|
||||||
|
if (currentPart > minPart) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -72,6 +74,12 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
|||||||
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
|
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
|
||||||
automaticallyImplyLeading:
|
automaticallyImplyLeading:
|
||||||
false, // Non può tornare indietro, deve mettere la password!
|
false, // Non può tornare indietro, deve mettere la password!
|
||||||
|
actions: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: () => context.read<SessionCubit>().signOut(),
|
||||||
|
icon: Icon(Icons.logout),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
fileBytes = file.localBytes;
|
fileBytes = file.localBytes;
|
||||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||||
fileBytes = await repository.downloadAttachmentBytes(
|
fileBytes = await repository.downloadAttachmentBytes(
|
||||||
file.storagePath!,
|
storagePath: file.storagePath!,
|
||||||
|
bucket: Bucket.documents,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +284,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
fileBytes = file.localBytes;
|
fileBytes = file.localBytes;
|
||||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||||
fileBytes = await repository.downloadAttachmentBytes(
|
fileBytes = await repository.downloadAttachmentBytes(
|
||||||
file.storagePath!,
|
storagePath: file.storagePath!,
|
||||||
|
bucket: Bucket.documents,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +426,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Cartella Export (Es. TIM AttachmentRepository)',
|
'Cartella Export PDF',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
@@ -561,8 +563,6 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
// Azioni visibili SOLO se c'è una selezione!
|
// Azioni visibili SOLO se c'è una selezione!
|
||||||
if (hasSelection) ...[
|
if (hasSelection) ...[
|
||||||
// Bottone Elimina
|
// Bottone Elimina
|
||||||
@@ -661,6 +661,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: const Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.upload_file, size: 48, color: Colors.grey),
|
Icon(Icons.upload_file, size: 48, color: Colors.grey),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||||
|
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
@@ -64,11 +65,17 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
if (hasCustomer) ...[
|
if (hasCustomer) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.pushNamed(
|
onPressed: () async {
|
||||||
|
final updatedCustomer = await context.pushNamed(
|
||||||
Routes.customerForm,
|
Routes.customerForm,
|
||||||
pathParameters: {'id': customer!.id!},
|
pathParameters: {'id': customer!.id!},
|
||||||
extra: customer,
|
extra: customer,
|
||||||
),
|
);
|
||||||
|
if (updatedCustomer != null &&
|
||||||
|
updatedCustomer is CustomerModel) {
|
||||||
|
onCustomerSelected(updatedCustomer);
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -253,7 +260,7 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
onChanged: (query) {
|
onChanged: (query) {
|
||||||
currentSearchQuery = query;
|
currentSearchQuery = query;
|
||||||
context.read<CustomersCubit>().searchCustomers(query);
|
context.read<CustomersListCubit>().searchCustomers(query);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -272,11 +279,14 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return BlocProvider.value(
|
return BlocProvider.value(
|
||||||
value: context.read<CustomersCubit>(),
|
value: context.read<CustomersListCubit>(),
|
||||||
|
child: BlocProvider<CustomerFormCubit>(
|
||||||
|
create: (context) => CustomerFormCubit(),
|
||||||
child: QuickCustomerDialog(
|
child: QuickCustomerDialog(
|
||||||
initialQuery:
|
initialQuery:
|
||||||
currentSearchQuery, // <-- Passiamo quello che ha digitato!
|
currentSearchQuery, // <-- Passiamo quello che ha digitato!
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -297,9 +307,9 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
// Lista Clienti dal Bloc
|
// Lista Clienti dal Bloc
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
child: BlocBuilder<CustomersListCubit, CustomersListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == CustomersStatus.loading) {
|
if (state.status == CustomersListStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (state.customers.isEmpty) {
|
if (state.customers.isEmpty) {
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class SharedModelSection extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Cerca modello (es. iPhone 15...)',
|
hintText: 'Cerca modello (es. iPhone 15...)',
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
|
||||||
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
|
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
|
||||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
@@ -98,6 +99,8 @@ class SharedFilesSection extends StatelessWidget {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => BlocProvider.value(
|
builder: (_) => BlocProvider.value(
|
||||||
value: bloc,
|
value: bloc,
|
||||||
|
child: BlocProvider<ImageUploadCubit>(
|
||||||
|
create: (context) => ImageUploadCubit(),
|
||||||
child: ImageUploadScreen(
|
child: ImageUploadScreen(
|
||||||
title: titleNameForUpload,
|
title: titleNameForUpload,
|
||||||
companyId: GetIt.I
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
add(LoadAttachmentsEvent(parentId: parentId));
|
add(LoadAttachmentsEvent(parentId: parentId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FutureOr<void> _onParentEntitySaved(
|
FutureOr<void> _onParentEntitySaved(
|
||||||
ParentEntitySavedEvent event,
|
ParentEntitySavedEvent event,
|
||||||
Emitter<AttachmentsState> emit,
|
Emitter<AttachmentsState> emit,
|
||||||
@@ -67,6 +68,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
parentType: state.parentType,
|
parentType: state.parentType,
|
||||||
pickedFile: fakePlatformFile,
|
pickedFile: fakePlatformFile,
|
||||||
companyId: companyId!,
|
companyId: companyId!,
|
||||||
|
bucket: _getBucketForParentType,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
parentType: state.parentType,
|
parentType: state.parentType,
|
||||||
pickedFile: file,
|
pickedFile: file,
|
||||||
companyId: companyId!,
|
companyId: companyId!,
|
||||||
|
bucket: _getBucketForParentType,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -192,6 +195,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
parentType: state.parentType,
|
parentType: state.parentType,
|
||||||
pickedFile: file,
|
pickedFile: file,
|
||||||
companyId: event.companyId,
|
companyId: event.companyId,
|
||||||
|
bucket: _getBucketForParentType,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,6 +222,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
parentType: state.parentType,
|
parentType: state.parentType,
|
||||||
pickedFile: fakePlatformFile,
|
pickedFile: fakePlatformFile,
|
||||||
companyId: event.companyId,
|
companyId: event.companyId,
|
||||||
|
bucket: _getBucketForParentType,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,6 +247,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
await _repository.deleteFiles(
|
await _repository.deleteFiles(
|
||||||
files: state.selectedFiles,
|
files: state.selectedFiles,
|
||||||
currentContextType: state.parentType,
|
currentContextType: state.parentType,
|
||||||
|
bucket: _getBucketForParentType,
|
||||||
);
|
);
|
||||||
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
|
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -298,6 +304,10 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
return file.copyWith(ticketId: event.targetId);
|
return file.copyWith(ticketId: event.targetId);
|
||||||
case AttachmentParentType.operation:
|
case AttachmentParentType.operation:
|
||||||
return file.copyWith(operationId: event.targetId);
|
return file.copyWith(operationId: event.targetId);
|
||||||
|
case AttachmentParentType.shippingDocument:
|
||||||
|
return file.copyWith(shippingDocumentId: event.targetId);
|
||||||
|
case AttachmentParentType.note:
|
||||||
|
return file.copyWith(noteId: event.targetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return file;
|
return file;
|
||||||
@@ -386,4 +396,19 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
emit(state.copyWith(localFiles: updatedLocalFiles));
|
emit(state.copyWith(localFiles: updatedLocalFiles));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Bucket get _getBucketForParentType {
|
||||||
|
switch (state.parentType) {
|
||||||
|
case AttachmentParentType.customer:
|
||||||
|
return Bucket.documents;
|
||||||
|
case AttachmentParentType.ticket:
|
||||||
|
return Bucket.documents;
|
||||||
|
case AttachmentParentType.operation:
|
||||||
|
return Bucket.documents;
|
||||||
|
case AttachmentParentType.shippingDocument:
|
||||||
|
return Bucket.companyDocuments;
|
||||||
|
case AttachmentParentType.note:
|
||||||
|
return Bucket.documents;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
|
|||||||
enum AttachmentParentType {
|
enum AttachmentParentType {
|
||||||
operation('operation_id'),
|
operation('operation_id'),
|
||||||
ticket('ticket_id'),
|
ticket('ticket_id'),
|
||||||
customer('customer_id');
|
customer('customer_id'),
|
||||||
|
shippingDocument('shipping_document_id'),
|
||||||
|
note('note_id');
|
||||||
|
|
||||||
final String dbColumn;
|
final String dbColumn;
|
||||||
const AttachmentParentType(this.dbColumn);
|
const AttachmentParentType(this.dbColumn);
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||||
|
|
||||||
|
enum Bucket {
|
||||||
|
documents('documents'),
|
||||||
|
companyDocuments('company_documents');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const Bucket(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
class AttachmentsRepository {
|
class AttachmentsRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = Supabase.instance.client;
|
||||||
static const String _bucketName = 'documents';
|
static const String _tableName = Tables.attachments;
|
||||||
static const String _tableName =
|
|
||||||
'attachment'; // Cambia col vero nome della tua tabella se diverso!
|
|
||||||
|
|
||||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||||
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
Future<Uint8List> downloadAttachmentBytes({
|
||||||
|
required String storagePath,
|
||||||
|
required Bucket bucket,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final Uint8List bytes = await _supabase.storage
|
final Uint8List bytes = await _supabase.storage
|
||||||
.from(_bucketName)
|
.from(bucket.value)
|
||||||
.download(storagePath);
|
.download(storagePath);
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -31,6 +41,10 @@ class AttachmentsRepository {
|
|||||||
return 'ticket_id';
|
return 'ticket_id';
|
||||||
case AttachmentParentType.customer:
|
case AttachmentParentType.customer:
|
||||||
return 'customer_id';
|
return 'customer_id';
|
||||||
|
case AttachmentParentType.shippingDocument:
|
||||||
|
return 'shipping_document_id';
|
||||||
|
case AttachmentParentType.note:
|
||||||
|
return 'note_id';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,41 +69,70 @@ class AttachmentsRepository {
|
|||||||
Future<void> uploadAndRegisterFile({
|
Future<void> uploadAndRegisterFile({
|
||||||
required String parentId,
|
required String parentId,
|
||||||
required AttachmentParentType parentType,
|
required AttachmentParentType parentType,
|
||||||
required PlatformFile pickedFile,
|
|
||||||
required String companyId,
|
required String companyId,
|
||||||
|
required Bucket bucket,
|
||||||
|
PlatformFile? pickedFile, // Ora è opzionale
|
||||||
|
Uint8List? rawBytes, // Alternativa: bytes grezzi
|
||||||
|
String? rawFileName, // Alternativa: nome del file
|
||||||
}) async {
|
}) async {
|
||||||
|
// 🛡️ L'ASSERT NINJA: O c'è il pickedFile, o ci sono i byte e il nome.
|
||||||
|
// L'assert funziona solo in debug, ma è perfetto per beccare subito errori di chiamata!
|
||||||
|
assert(
|
||||||
|
pickedFile != null || (rawBytes != null && rawFileName != null),
|
||||||
|
'Devi passare o un PlatformFile, oppure rawBytes e rawFileName!',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 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) {
|
if (pickedFile.bytes == null) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
"I bytes del file sono vuoti! Ricarica la pagina senza cache.",
|
"I bytes del file sono vuoti! Ricarica la pagina senza cache.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
finalBytes = pickedFile.bytes!;
|
||||||
|
finalFileName = pickedFile.name;
|
||||||
|
finalFileSize = pickedFile.size;
|
||||||
|
} else {
|
||||||
|
// Se pickedFile è null, grazie all'assert sappiamo che questi non lo sono
|
||||||
|
finalBytes = rawBytes!;
|
||||||
|
finalFileName = rawFileName!;
|
||||||
|
finalFileSize = finalBytes.length; // Calcoliamo la size dai byte reali
|
||||||
|
}
|
||||||
|
|
||||||
final extension = pickedFile.extension ?? pickedFile.name.split('.').last;
|
// 2. Estraiamo l'estensione e puliamo il nome
|
||||||
final cleanName = pickedFile.name
|
final extension = finalFileName.contains('.')
|
||||||
|
? finalFileName.split('.').last
|
||||||
|
: ''; // Fallback se il file non ha estensione
|
||||||
|
|
||||||
|
final cleanName = finalFileName
|
||||||
.replaceAll(RegExp(r'[^\w\s\.-]'), '')
|
.replaceAll(RegExp(r'[^\w\s\.-]'), '')
|
||||||
.replaceAll(' ', '_');
|
.replaceAll(' ', '_');
|
||||||
|
|
||||||
// Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
|
// 3. Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
final storagePath =
|
final storagePath =
|
||||||
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
|
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
|
||||||
|
|
||||||
// 1. Upload su Supabase Storage
|
// 4. Upload su Supabase Storage
|
||||||
await _supabase.storage
|
await _supabase.storage
|
||||||
.from(_bucketName)
|
.from(bucket.value)
|
||||||
.uploadBinary(
|
.uploadBinary(
|
||||||
storagePath,
|
storagePath,
|
||||||
pickedFile.bytes!,
|
finalBytes,
|
||||||
fileOptions: FileOptions(contentType: _guessContentType(extension)),
|
fileOptions: FileOptions(contentType: _guessContentType(extension)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Creiamo la mappa per il DB dinamicamente
|
// 5. Creiamo la mappa per il DB dinamicamente
|
||||||
final Map<String, dynamic> insertData = {
|
final Map<String, dynamic> insertData = {
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
'name': pickedFile.name.replaceAll('.$extension', ''),
|
'name': finalFileName.replaceAll('.$extension', ''),
|
||||||
'extension': extension,
|
'extension': extension,
|
||||||
'file_size': pickedFile.size,
|
'file_size': finalFileSize,
|
||||||
'storage_path': storagePath,
|
'storage_path': storagePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +140,7 @@ class AttachmentsRepository {
|
|||||||
final columnName = _getColumnNameForParent(parentType);
|
final columnName = _getColumnNameForParent(parentType);
|
||||||
insertData[columnName] = parentId;
|
insertData[columnName] = parentId;
|
||||||
|
|
||||||
// 3. Salviamo su Postgres
|
// 6. Salviamo su Postgres
|
||||||
await _supabase.from(_tableName).insert(insertData);
|
await _supabase.from(_tableName).insert(insertData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Errore caricamento: $e");
|
throw Exception("Errore caricamento: $e");
|
||||||
@@ -108,6 +151,7 @@ class AttachmentsRepository {
|
|||||||
Future<void> deleteFiles({
|
Future<void> deleteFiles({
|
||||||
required List<AttachmentModel> files,
|
required List<AttachmentModel> files,
|
||||||
required AttachmentParentType currentContextType,
|
required AttachmentParentType currentContextType,
|
||||||
|
required Bucket bucket,
|
||||||
}) async {
|
}) async {
|
||||||
if (files.isEmpty) return;
|
if (files.isEmpty) return;
|
||||||
|
|
||||||
@@ -120,6 +164,7 @@ class AttachmentsRepository {
|
|||||||
AttachmentParentType.operation: file.operationId,
|
AttachmentParentType.operation: file.operationId,
|
||||||
AttachmentParentType.ticket: file.ticketId,
|
AttachmentParentType.ticket: file.ticketId,
|
||||||
AttachmentParentType.customer: file.customerId,
|
AttachmentParentType.customer: file.customerId,
|
||||||
|
AttachmentParentType.shippingDocument: file.shippingDocumentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Simuliamo la rimozione del collegamento per il contesto attuale
|
// 2. Simuliamo la rimozione del collegamento per il contesto attuale
|
||||||
@@ -142,7 +187,7 @@ class AttachmentsRepository {
|
|||||||
await _supabase.from(_tableName).delete().eq('id', file.id!);
|
await _supabase.from(_tableName).delete().eq('id', file.id!);
|
||||||
|
|
||||||
if (file.storagePath != null) {
|
if (file.storagePath != null) {
|
||||||
await _supabase.storage.from(_bucketName).remove([
|
await _supabase.storage.from(bucket.value).remove([
|
||||||
file.storagePath!,
|
file.storagePath!,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class AttachmentModel extends Equatable {
|
|||||||
final String? customerId;
|
final String? customerId;
|
||||||
final String? operationId;
|
final String? operationId;
|
||||||
final String? ticketId;
|
final String? ticketId;
|
||||||
|
final String? shippingDocumentId;
|
||||||
|
final String? noteId;
|
||||||
final String name;
|
final String name;
|
||||||
final String extension;
|
final String extension;
|
||||||
final String? storagePath;
|
final String? storagePath;
|
||||||
@@ -21,6 +23,8 @@ class AttachmentModel extends Equatable {
|
|||||||
this.customerId,
|
this.customerId,
|
||||||
this.operationId,
|
this.operationId,
|
||||||
this.ticketId,
|
this.ticketId,
|
||||||
|
this.shippingDocumentId,
|
||||||
|
this.noteId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.extension,
|
required this.extension,
|
||||||
this.storagePath,
|
this.storagePath,
|
||||||
@@ -36,6 +40,8 @@ class AttachmentModel extends Equatable {
|
|||||||
customerId,
|
customerId,
|
||||||
operationId,
|
operationId,
|
||||||
ticketId,
|
ticketId,
|
||||||
|
shippingDocumentId,
|
||||||
|
noteId,
|
||||||
name,
|
name,
|
||||||
extension,
|
extension,
|
||||||
storagePath,
|
storagePath,
|
||||||
@@ -63,6 +69,8 @@ class AttachmentModel extends Equatable {
|
|||||||
String? customerId,
|
String? customerId,
|
||||||
String? operationId,
|
String? operationId,
|
||||||
String? ticketId,
|
String? ticketId,
|
||||||
|
String? shippingDocumentId,
|
||||||
|
String? noteId,
|
||||||
String? name,
|
String? name,
|
||||||
String? extension,
|
String? extension,
|
||||||
String? storagePath,
|
String? storagePath,
|
||||||
@@ -75,6 +83,8 @@ class AttachmentModel extends Equatable {
|
|||||||
customerId: customerId ?? this.customerId,
|
customerId: customerId ?? this.customerId,
|
||||||
operationId: operationId ?? this.operationId,
|
operationId: operationId ?? this.operationId,
|
||||||
ticketId: ticketId ?? this.ticketId,
|
ticketId: ticketId ?? this.ticketId,
|
||||||
|
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,
|
||||||
|
noteId: noteId ?? this.noteId,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
extension: extension ?? this.extension,
|
extension: extension ?? this.extension,
|
||||||
storagePath: storagePath ?? this.storagePath,
|
storagePath: storagePath ?? this.storagePath,
|
||||||
@@ -92,6 +102,8 @@ class AttachmentModel extends Equatable {
|
|||||||
customerId: map['customer_id'] as String?,
|
customerId: map['customer_id'] as String?,
|
||||||
operationId: map['operation_id'] as String?,
|
operationId: map['operation_id'] as String?,
|
||||||
ticketId: map['ticket_id'] as String?,
|
ticketId: map['ticket_id'] as String?,
|
||||||
|
shippingDocumentId: map['shipping_document_id'] as String?,
|
||||||
|
noteId: map['note_id'] as String?,
|
||||||
name: map['name'] as String,
|
name: map['name'] as String,
|
||||||
extension: map['extension'] as String,
|
extension: map['extension'] as String,
|
||||||
storagePath: map['storage_path'] as String?,
|
storagePath: map['storage_path'] as String?,
|
||||||
@@ -111,6 +123,8 @@ class AttachmentModel extends Equatable {
|
|||||||
'customer_id': customerId,
|
'customer_id': customerId,
|
||||||
'operation_id': operationId,
|
'operation_id': operationId,
|
||||||
'ticket_id': ticketId,
|
'ticket_id': ticketId,
|
||||||
|
'shipping_document_id': shippingDocumentId,
|
||||||
|
'note_id': noteId,
|
||||||
'file_size': fileSize,
|
'file_size': fileSize,
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/data/constants.dart';
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/core/utils/app_message.dart';
|
import 'package:flux/core/utils/app_message.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/company_model.dart';
|
import '../models/company_model.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class CompanyRepository {
|
|||||||
try {
|
try {
|
||||||
// .select().single() trasforma la risposta nell'oggetto appena inserito
|
// .select().single() trasforma la risposta nell'oggetto appena inserito
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.insert(company.toMap())
|
.insert(company.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -26,7 +27,7 @@ class CompanyRepository {
|
|||||||
Future<CompanyModel> updateCompany(CompanyModel company) async {
|
Future<CompanyModel> updateCompany(CompanyModel company) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.update(company.toMap())
|
.update(company.toMap())
|
||||||
.eq('id', company.id!)
|
.eq('id', company.id!)
|
||||||
.select()
|
.select()
|
||||||
@@ -83,7 +84,7 @@ class CompanyRepository {
|
|||||||
try {
|
try {
|
||||||
final userId = _supabase.auth.currentUser?.id;
|
final userId = _supabase.auth.currentUser?.id;
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('company')
|
.from(Tables.companies)
|
||||||
.select()
|
.select()
|
||||||
.eq('user_id', userId as Object)
|
.eq('user_id', userId as Object)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|||||||
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,
|
initial,
|
||||||
loading,
|
loading,
|
||||||
filesLoading,
|
filesLoading,
|
||||||
@@ -9,26 +9,26 @@ enum CustomersStatus {
|
|||||||
failure,
|
failure,
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomersState extends Equatable {
|
class CustomersListState extends Equatable {
|
||||||
final CustomersStatus status;
|
final CustomersListStatus status;
|
||||||
final List<CustomerModel> customers;
|
final List<CustomerModel> customers;
|
||||||
final CustomerModel? lastCreatedCustomer;
|
final CustomerModel? lastCreatedCustomer;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
const CustomersState({
|
const CustomersListState({
|
||||||
this.status = CustomersStatus.initial,
|
this.status = CustomersListStatus.initial,
|
||||||
this.customers = const [],
|
this.customers = const [],
|
||||||
this.lastCreatedCustomer,
|
this.lastCreatedCustomer,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
CustomersState copyWith({
|
CustomersListState copyWith({
|
||||||
CustomersStatus? status,
|
CustomersListStatus? status,
|
||||||
List<CustomerModel>? customers,
|
List<CustomerModel>? customers,
|
||||||
CustomerModel? lastCreatedCustomer,
|
CustomerModel? lastCreatedCustomer,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return CustomersState(
|
return CustomersListState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
customers: customers ?? this.customers,
|
customers: customers ?? this.customers,
|
||||||
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -11,10 +12,10 @@ class CustomerRepository {
|
|||||||
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
|
||||||
// Crea un nuovo cliente
|
// Crea un nuovo cliente
|
||||||
Future<CustomerModel> saveCustomer(CustomerModel customer) async {
|
Future<CustomerModel> insertCustomer(CustomerModel customer) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('customer')
|
.from(Tables.customers)
|
||||||
.upsert(customer.toJson())
|
.upsert(customer.toJson())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -27,7 +28,7 @@ class CustomerRepository {
|
|||||||
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
|
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('customer')
|
.from(Tables.customers)
|
||||||
.update(customer.toJson())
|
.update(customer.toJson())
|
||||||
.eq('id', customer.id!)
|
.eq('id', customer.id!)
|
||||||
.select()
|
.select()
|
||||||
@@ -42,14 +43,14 @@ class CustomerRepository {
|
|||||||
Future<List<CustomerModel>> getCustomers(String companyId) async {
|
Future<List<CustomerModel>> getCustomers(String companyId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('customer')
|
.from(Tables.customers)
|
||||||
.select('''
|
.select('''
|
||||||
*,
|
*,
|
||||||
attachment(*)
|
${Tables.attachments}(*)
|
||||||
''')
|
''')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.order('name');
|
.order('name', ascending: true);
|
||||||
|
|
||||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -57,6 +58,23 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<CustomerModel> getCustomerById(String customerId) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.customers)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
${Tables.attachments}(*)
|
||||||
|
''')
|
||||||
|
.eq('id', customerId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return CustomerModel.fromMap(response);
|
||||||
|
} catch (e) {
|
||||||
|
throw '$e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ricerca clienti per nome o telefono (fondamentale per la UX)
|
// Ricerca clienti per nome o telefono (fondamentale per la UX)
|
||||||
Future<List<CustomerModel>> searchCustomers(
|
Future<List<CustomerModel>> searchCustomers(
|
||||||
String companyId,
|
String companyId,
|
||||||
@@ -64,7 +82,7 @@ class CustomerRepository {
|
|||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('customer')
|
.from(Tables.customers)
|
||||||
.select()
|
.select()
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
|
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
|
||||||
@@ -79,7 +97,7 @@ class CustomerRepository {
|
|||||||
/// Ascolta in tempo reale i file caricati per un cliente
|
/// Ascolta in tempo reale i file caricati per un cliente
|
||||||
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
|
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
|
||||||
return _supabase
|
return _supabase
|
||||||
.from('attachment')
|
.from(Tables.attachments)
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.eq('customer_id', customerId)
|
.eq('customer_id', customerId)
|
||||||
.order('created_at', ascending: false)
|
.order('created_at', ascending: false)
|
||||||
@@ -93,7 +111,7 @@ class CustomerRepository {
|
|||||||
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
|
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('attachment')
|
.from(Tables.attachments)
|
||||||
.select()
|
.select()
|
||||||
.eq('customer_id', customerId);
|
.eq('customer_id', customerId);
|
||||||
|
|
||||||
@@ -144,7 +162,7 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('attachment')
|
.from(Tables.attachments)
|
||||||
.insert(fileToSave.toMap())
|
.insert(fileToSave.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -156,7 +174,7 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveFileReference(AttachmentModel file) async {
|
Future<void> saveFileReference(AttachmentModel file) async {
|
||||||
await _supabase.from('attachment').upsert(file.toMap());
|
await _supabase.from(Tables.attachments).upsert(file.toMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteDocuments(List<AttachmentModel> files) async {
|
Future<void> deleteDocuments(List<AttachmentModel> files) async {
|
||||||
@@ -175,13 +193,16 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (idsToDelete.isNotEmpty) {
|
if (idsToDelete.isNotEmpty) {
|
||||||
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
await _supabase
|
||||||
|
.from(Tables.attachments)
|
||||||
|
.delete()
|
||||||
|
.inFilter('id', idsToDelete);
|
||||||
// 3. Cancellazione MASSIVA dallo Storage
|
// 3. Cancellazione MASSIVA dallo Storage
|
||||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||||
}
|
}
|
||||||
if (idsToEdit.isNotEmpty) {
|
if (idsToEdit.isNotEmpty) {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('attachment')
|
.from(Tables.attachments)
|
||||||
.update({'customer_id': null})
|
.update({'customer_id': null})
|
||||||
.inFilter('id', idsToEdit);
|
.inFilter('id', idsToEdit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class CustomerModel extends Equatable {
|
|||||||
final String companyId; // UUID
|
final String companyId; // UUID
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final List<AttachmentModel> attachments;
|
final List<AttachmentModel> attachments;
|
||||||
|
final bool isBusiness;
|
||||||
|
|
||||||
const CustomerModel({
|
const CustomerModel({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -27,6 +28,7 @@ class CustomerModel extends Equatable {
|
|||||||
required this.companyId,
|
required this.companyId,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
|
this.isBusiness = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -42,8 +44,18 @@ class CustomerModel extends Equatable {
|
|||||||
companyId,
|
companyId,
|
||||||
isActive,
|
isActive,
|
||||||
attachments,
|
attachments,
|
||||||
|
isBusiness,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
factory CustomerModel.empty() => CustomerModel(
|
||||||
|
name: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
email: '',
|
||||||
|
note: '',
|
||||||
|
companyId:
|
||||||
|
'', // Dovrebbe essere sempre fornito, ma lasciamo vuoto per sicurezza
|
||||||
|
);
|
||||||
|
|
||||||
CustomerModel copyWith({
|
CustomerModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
@@ -56,6 +68,7 @@ class CustomerModel extends Equatable {
|
|||||||
String? companyId,
|
String? companyId,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
List<AttachmentModel>? attachments,
|
List<AttachmentModel>? attachments,
|
||||||
|
bool? isBusiness,
|
||||||
}) {
|
}) {
|
||||||
return CustomerModel(
|
return CustomerModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -69,6 +82,7 @@ class CustomerModel extends Equatable {
|
|||||||
companyId: companyId ?? this.companyId,
|
companyId: companyId ?? this.companyId,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
attachments: attachments ?? this.attachments,
|
attachments: attachments ?? this.attachments,
|
||||||
|
isBusiness: isBusiness ?? this.isBusiness,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +107,7 @@ class CustomerModel extends Equatable {
|
|||||||
?.map((x) => AttachmentModel.fromMap(x))
|
?.map((x) => AttachmentModel.fromMap(x))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
|
isBusiness: map['is_business'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +123,7 @@ class CustomerModel extends Equatable {
|
|||||||
'do_not_disturb': doNotDisturb,
|
'do_not_disturb': doNotDisturb,
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
'is_active': isActive,
|
'is_active': isActive,
|
||||||
|
'is_business': isBusiness,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_form.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class CustomersContent extends StatefulWidget {
|
class CustomersListScreen extends StatefulWidget {
|
||||||
const CustomersContent({super.key});
|
const CustomersListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CustomersContent> createState() => _CustomersContentState();
|
State<CustomersListScreen> createState() => _CustomersListScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomersContentState extends State<CustomersContent> {
|
class _CustomersListScreenState extends State<CustomersListScreen> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -27,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
void _loadInitialCustomers() {
|
void _loadInitialCustomers() {
|
||||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||||
if (companyId != null) {
|
if (companyId != null) {
|
||||||
context.read<CustomersCubit>().loadCustomers();
|
context.read<CustomersListCubit>().loadCustomers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearch(String query) {
|
void _onSearch(String query) {
|
||||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||||
if (companyId != null) {
|
if (companyId != null) {
|
||||||
context.read<CustomersCubit>().searchCustomers(query);
|
context.read<CustomersListCubit>().searchCustomers(query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +52,12 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => openCustomerForm(context: context),
|
onPressed: () {
|
||||||
|
context.pushNamed(
|
||||||
|
Routes.customerForm,
|
||||||
|
pathParameters: {'id': 'new'},
|
||||||
|
);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 20),
|
icon: const Icon(Icons.person_add_alt_1_rounded, size: 20),
|
||||||
label: const Text('NUOVO'),
|
label: const Text('NUOVO'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -87,9 +91,9 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
|
|
||||||
// LISTA CLIENTI
|
// LISTA CLIENTI
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
child: BlocBuilder<CustomersListCubit, CustomersListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == CustomersStatus.loading &&
|
if (state.status == CustomersListStatus.loading &&
|
||||||
state.customers.isEmpty) {
|
state.customers.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -111,7 +115,7 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
return _CustomerTile(
|
return _CustomerTile(
|
||||||
customer: customer,
|
customer: customer,
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.pushNamed(
|
||||||
Routes.customerForm,
|
Routes.customerDetails,
|
||||||
pathParameters: {'id': customer.id!},
|
pathParameters: {'id': customer.id!},
|
||||||
extra: customer,
|
extra: customer,
|
||||||
),
|
),
|
||||||
@@ -214,8 +218,16 @@ class _CustomerTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
onPressed: () =>
|
onPressed: () async {
|
||||||
openCustomerForm(context: context, customer: customer),
|
final CustomersListCubit customersCubit = context
|
||||||
|
.read<CustomersListCubit>();
|
||||||
|
await context.pushNamed(
|
||||||
|
Routes.customerForm,
|
||||||
|
pathParameters: {'id': customer.id!},
|
||||||
|
extra: customer,
|
||||||
|
);
|
||||||
|
customersCubit.loadCustomers();
|
||||||
|
},
|
||||||
icon: Icon(Icons.edit_note_rounded, color: context.accent),
|
icon: Icon(Icons.edit_note_rounded, color: context.accent),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -224,7 +236,7 @@ class _CustomerTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Funzione unica per gestire Creazione e Modifica
|
/// Funzione unica per gestire Creazione e Modifica
|
||||||
void openCustomerForm({
|
/* void openCustomerForm({
|
||||||
CustomerModel? customer,
|
CustomerModel? customer,
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
}) {
|
}) {
|
||||||
@@ -257,4 +269,4 @@ void openCustomerForm({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
} */
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||||
|
|
||||||
class QuickCustomerDialog extends StatefulWidget {
|
class QuickCustomerDialog extends StatefulWidget {
|
||||||
final String initialQuery;
|
final String initialQuery;
|
||||||
@@ -16,6 +16,7 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
|||||||
final _phoneCtrl = TextEditingController();
|
final _phoneCtrl = TextEditingController();
|
||||||
final _emailCtrl = TextEditingController();
|
final _emailCtrl = TextEditingController();
|
||||||
final _noteCtrl = TextEditingController();
|
final _noteCtrl = TextEditingController();
|
||||||
|
bool _isBusiness = false; // Aggiungiamo un campo per il tipo di cliente
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@@ -43,13 +44,12 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
|||||||
|
|
||||||
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
|
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
|
||||||
final newCustomer = await context
|
final newCustomer = await context
|
||||||
.read<CustomersCubit>()
|
.read<CustomerFormCubit>()
|
||||||
.quickCreateCustomer(
|
.quickCreateCustomer(
|
||||||
|
isBusiness: _isBusiness,
|
||||||
name: _nameCtrl.text.trim(),
|
name: _nameCtrl.text.trim(),
|
||||||
phone: _phoneCtrl.text.trim(),
|
phone: _phoneCtrl.text.trim(),
|
||||||
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
|
email: _emailCtrl.text.trim(),
|
||||||
// email: _emailCtrl.text.trim(),
|
|
||||||
// note: _noteCtrl.text.trim(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@@ -67,6 +67,41 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Privato (Domestico)'),
|
||||||
|
selected: _isBusiness == false,
|
||||||
|
selectedColor: Colors.blue.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: Colors.blue.shade700,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() {
|
||||||
|
_isBusiness = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Business (P.IVA)'),
|
||||||
|
selected: _isBusiness == true,
|
||||||
|
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: Colors.orange.shade700,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() {
|
||||||
|
_isBusiness = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
autofocus: true, // Focus immediato!
|
autofocus: true, // Focus immediato!
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ class LatestStoreOperationsBloc
|
|||||||
status: LatestStoreOperationsStatus.initial,
|
status: LatestStoreOperationsStatus.initial,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
on<InitLastStoreOperationsEvent>((event, emit) async {
|
on<InitLatestStoreOperationsEvent>((event, emit) async {
|
||||||
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
|
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
|
||||||
try {
|
try {
|
||||||
// 1. Creiamo uno stream "intermedio" che idrata i dati
|
// 1. Creiamo uno stream "intermedio" che idrata i dati
|
||||||
final hydratedStream = _repository
|
final hydratedStream = _repository
|
||||||
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5)
|
.getLatestStoreOperationsStream(storeId: event.storeId, limit: 10)
|
||||||
.asyncMap((List<OperationModel> rawOperations) async {
|
.asyncMap((List<OperationModel> rawOperations) async {
|
||||||
// Questo gira ad ogni "scatto" dello stream di Supabase
|
// Questo gira ad ogni "scatto" dello stream di Supabase
|
||||||
List<OperationModel> fullyHydratedOperations = [];
|
List<OperationModel> fullyHydratedOperations = [];
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ sealed class LatestStoreOperationsEvent extends Equatable {
|
|||||||
List<Object> get props => [];
|
List<Object> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent {
|
class InitLatestStoreOperationsEvent extends LatestStoreOperationsEvent {
|
||||||
final String storeId;
|
final String storeId;
|
||||||
|
|
||||||
const InitLastStoreOperationsEvent(this.storeId);
|
const InitLatestStoreOperationsEvent(this.storeId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [storeId];
|
List<Object> get props => [storeId];
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
|
|||||||
// 1. Creiamo il Bloc e facciamo partire subito la query
|
// 1. Creiamo il Bloc e facciamo partire subito la query
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
LatestStoreOperationsBloc()
|
LatestStoreOperationsBloc()
|
||||||
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')),
|
..add(InitLatestStoreOperationsEvent(currentStoreId ?? '')),
|
||||||
child: BlocListener<SessionCubit, SessionState>(
|
child: BlocListener<SessionCubit, SessionState>(
|
||||||
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
|
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
@@ -26,7 +26,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
|
|||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state.currentStore?.id != null) {
|
if (state.currentStore?.id != null) {
|
||||||
context.read<LatestStoreOperationsBloc>().add(
|
context.read<LatestStoreOperationsBloc>().add(
|
||||||
InitLastStoreOperationsEvent(state.currentStore!.id!),
|
InitLatestStoreOperationsEvent(state.currentStore!.id!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -45,13 +45,13 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.pushNamed(Routes.operations),
|
onTap: () => context.pushNamed(Routes.operations),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -137,8 +137,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.pushNamed(
|
||||||
Routes.operationForm,
|
Routes.operationForm,
|
||||||
|
extra: (createdBy: null, operation: operation),
|
||||||
pathParameters: {'id': operation.id!},
|
pathParameters: {'id': operation.id!},
|
||||||
extra: operation,
|
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -162,7 +162,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 5,
|
||||||
child: Text(
|
child: Text(
|
||||||
operation.reference,
|
operation.type,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: context.primaryText,
|
color: context.primaryText,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
||||||
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
part 'latest_store_tickets_events.dart';
|
||||||
|
part 'latest_store_tickets_state.dart';
|
||||||
|
|
||||||
|
class LatestStoreTicketsBloc
|
||||||
|
extends Bloc<LatestStoreTicketsEvent, LatestStoreTicketsState> {
|
||||||
|
final _repository = GetIt.I.get<TicketRepository>();
|
||||||
|
LatestStoreTicketsBloc()
|
||||||
|
: super(
|
||||||
|
const LatestStoreTicketsState(status: LatestStoreTicketsStatus.initial),
|
||||||
|
) {
|
||||||
|
on<InitLatestStoreTicketsEvent>((event, emit) async {
|
||||||
|
emit(state.copyWith(status: LatestStoreTicketsStatus.loading));
|
||||||
|
try {
|
||||||
|
final hydratedStream = _repository
|
||||||
|
.getLatestStoreTicketsStream(storeId: event.storeId, limit: 10)
|
||||||
|
.asyncMap((List<TicketModel> rawTickets) async {
|
||||||
|
List<TicketModel> fullyHydratedTickets = [];
|
||||||
|
|
||||||
|
for (TicketModel ticket in rawTickets) {
|
||||||
|
TicketModel fullTicket = await _repository.getTicketById(
|
||||||
|
ticket.id!,
|
||||||
|
);
|
||||||
|
fullyHydratedTickets.add(fullTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullyHydratedTickets;
|
||||||
|
});
|
||||||
|
await emit.forEach(
|
||||||
|
hydratedStream,
|
||||||
|
onData: (List<TicketModel> fullyHydratedTickets) {
|
||||||
|
return state.copyWith(
|
||||||
|
tickets: fullyHydratedTickets,
|
||||||
|
status: LatestStoreTicketsStatus.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error, stackTrace) => state.copyWith(
|
||||||
|
status: LatestStoreTicketsStatus.failure,
|
||||||
|
error: error.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: LatestStoreTicketsStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// TODO: implement event handlers
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
part of 'latest_store_tickets_bloc.dart';
|
||||||
|
|
||||||
|
abstract class LatestStoreTicketsEvent extends Equatable {
|
||||||
|
const LatestStoreTicketsEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class InitLatestStoreTicketsEvent extends LatestStoreTicketsEvent {
|
||||||
|
final String storeId;
|
||||||
|
|
||||||
|
const InitLatestStoreTicketsEvent(this.storeId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [storeId];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
part of 'latest_store_tickets_bloc.dart';
|
||||||
|
|
||||||
|
enum LatestStoreTicketsStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class LatestStoreTicketsState extends Equatable {
|
||||||
|
final LatestStoreTicketsStatus status;
|
||||||
|
final String? error;
|
||||||
|
final List<TicketModel> tickets;
|
||||||
|
const LatestStoreTicketsState({
|
||||||
|
required this.status,
|
||||||
|
this.error,
|
||||||
|
this.tickets = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, error, tickets];
|
||||||
|
|
||||||
|
LatestStoreTicketsState copyWith({
|
||||||
|
LatestStoreTicketsStatus? status,
|
||||||
|
String? error,
|
||||||
|
List<TicketModel>? tickets,
|
||||||
|
}) {
|
||||||
|
return LatestStoreTicketsState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
error: error,
|
||||||
|
tickets: tickets ?? this.tickets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/routes/routes.dart';
|
||||||
|
import 'package:flux/core/theme/theme.dart';
|
||||||
|
import 'package:flux/features/home/latest_store_tickets/blocs/latest_store_tickets_bloc.dart';
|
||||||
|
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class LatestStoreTicketsCard extends StatelessWidget {
|
||||||
|
const LatestStoreTicketsCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
|
||||||
|
|
||||||
|
return BlocProvider(
|
||||||
|
// 1. Creiamo il Bloc e facciamo partire subito la query
|
||||||
|
create: (context) =>
|
||||||
|
LatestStoreTicketsBloc()
|
||||||
|
..add(InitLatestStoreTicketsEvent(currentStoreId ?? '')),
|
||||||
|
child: BlocListener<SessionCubit, SessionState>(
|
||||||
|
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
|
||||||
|
listenWhen: (previous, current) =>
|
||||||
|
previous.currentStore?.id != current.currentStore?.id,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.currentStore?.id != null) {
|
||||||
|
context.read<LatestStoreTicketsBloc>().add(
|
||||||
|
InitLatestStoreTicketsEvent(state.currentStore!.id!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _LatestStoreTicketsCardContent(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LatestStoreTicketsCardContent extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
const color = Colors.blue;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.pushNamed(Routes.tickets),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// --- HEADER DELLA CARD ---
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.design_services_outlined,
|
||||||
|
color: color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"Ticket recenti",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<LatestStoreTicketsBloc, LatestStoreTicketsState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == LatestStoreTicketsStatus.loading ||
|
||||||
|
state.status == LatestStoreTicketsStatus.initial) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == LatestStoreTicketsStatus.failure) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Errore di caricamento",
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.tickets.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Nessun ticket recente.",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.secondaryText,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: state.tickets.length,
|
||||||
|
separatorBuilder: (context, index) => Divider(
|
||||||
|
height: 1,
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ticket = state.tickets[index];
|
||||||
|
final statusColor = ticket.ticketStatus.color;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.ticketForm,
|
||||||
|
extra: (createdBy: null, ticket: ticket),
|
||||||
|
pathParameters: {'id': ticket.id!},
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height:
|
||||||
|
30, // Un'altezza fissa per farlo comparire!
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
4,
|
||||||
|
), // Angoli smussati per stile
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Text(
|
||||||
|
ticket.customer?.name ??
|
||||||
|
'Cliente sconosciuto',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Text(
|
||||||
|
ticket.targetModelName ??
|
||||||
|
'Modello sconosciuto',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,16 @@ import 'package:flux/core/blocs/session/session_cubit.dart';
|
|||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
|
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
||||||
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
||||||
|
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
|
||||||
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
|
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||||
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
|
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
@@ -59,34 +66,21 @@ class HomeScreen extends StatelessWidget {
|
|||||||
childAspectRatio: 1.3,
|
childAspectRatio: 1.3,
|
||||||
),
|
),
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
|
LatestStoreOperationsCard(),
|
||||||
|
LatestStoreTicketsCard(),
|
||||||
_buildDashboardWidget(
|
_buildDashboardWidget(
|
||||||
title: context.l10n.homeExpiringContracts,
|
title: context.l10n.homeExpiringContracts,
|
||||||
icon: Icons.assignment_late_outlined,
|
icon: Icons.assignment_late_outlined,
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
_buildDashboardWidget(
|
DashboardNotesWidget(),
|
||||||
title: context.l10n.commonStickyNotes,
|
|
||||||
icon: Icons.sticky_note_2_outlined,
|
|
||||||
color: Colors.yellow.shade700,
|
|
||||||
context: context,
|
|
||||||
),
|
|
||||||
_buildDashboardWidget(
|
_buildDashboardWidget(
|
||||||
title: context.l10n.homeMyTasks,
|
title: context.l10n.homeMyTasks,
|
||||||
icon: Icons.check_box_outlined,
|
icon: Icons.check_box_outlined,
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
LatestStoreOperationsCard(),
|
|
||||||
_buildDashboardWidget(
|
|
||||||
title: context.l10n.homeLatestOperationTickets,
|
|
||||||
icon: Icons.support_agent_outlined,
|
|
||||||
color: Colors.purple,
|
|
||||||
context: context,
|
|
||||||
onTap: () => context.pushNamed(
|
|
||||||
Routes.tickets,
|
|
||||||
), // <-- Aggiunto!
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -186,11 +180,13 @@ class HomeScreen extends StatelessWidget {
|
|||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
label: context.l10n.commonOperation,
|
label: context.l10n.commonOperation,
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
|
StaffMemberModel? createdBy = await getStaffMember(context);
|
||||||
|
if (createdBy == null || !context.mounted) return;
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
Routes.operationForm,
|
Routes.operationForm,
|
||||||
pathParameters: {'id': 'new'},
|
pathParameters: {'id': 'new'},
|
||||||
|
extra: (createdBy: createdBy, operation: null),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -199,11 +195,13 @@ class HomeScreen extends StatelessWidget {
|
|||||||
icon: Icons.handyman,
|
icon: Icons.handyman,
|
||||||
label: context.l10n.homeNewOperationTicket,
|
label: context.l10n.homeNewOperationTicket,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
// Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form)
|
StaffMemberModel? createdBy = await getStaffMember(context);
|
||||||
|
if (createdBy == null || !context.mounted) return;
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
Routes.ticketForm,
|
Routes.ticketForm,
|
||||||
pathParameters: {'id': 'new'},
|
pathParameters: {'id': 'new'},
|
||||||
|
extra: (createdBy: createdBy, ticket: null),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -212,8 +210,26 @@ class HomeScreen extends StatelessWidget {
|
|||||||
icon: Icons.note_add,
|
icon: Icons.note_add,
|
||||||
label: context.l10n.commonNote,
|
label: context.l10n.commonNote,
|
||||||
color: Colors.amber,
|
color: Colors.amber,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
// TODO: Quando faremo il modale/pagina delle note
|
final companyId = context.read<SessionCubit>().state.company!.id!;
|
||||||
|
final currentStaffId = context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStaffMember!
|
||||||
|
.id!;
|
||||||
|
final emptyNote = NoteModel.empty(
|
||||||
|
createdBy: currentStaffId,
|
||||||
|
companyId: companyId,
|
||||||
|
);
|
||||||
|
final noteId = await GetIt.I.get<NotesRepository>().saveNote(
|
||||||
|
emptyNote,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.pushNamed(
|
||||||
|
Routes.noteForm,
|
||||||
|
pathParameters: {'id': noteId},
|
||||||
|
extra: emptyNote.copyWith(id: noteId),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -383,6 +399,7 @@ class HomeScreen extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
// Cambiamo il negozio nel SessionCubit!
|
// Cambiamo il negozio nel SessionCubit!
|
||||||
context.read<SessionCubit>().changeStore(store);
|
context.read<SessionCubit>().changeStore(store);
|
||||||
|
context.read<StaffCubit>().loadStaffForStore(store.id!);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/brand_model.dart';
|
import '../models/brand_model.dart';
|
||||||
@@ -14,7 +15,7 @@ class ProductRepository {
|
|||||||
Future<List<BrandModel>> getBrands() async {
|
Future<List<BrandModel>> getBrands() async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('brand')
|
.from(Tables.brands)
|
||||||
.select()
|
.select()
|
||||||
.eq('company_id', _companyId)
|
.eq('company_id', _companyId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
@@ -30,7 +31,7 @@ class ProductRepository {
|
|||||||
Future<BrandModel> upsertBrand(BrandModel brand) async {
|
Future<BrandModel> upsertBrand(BrandModel brand) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('brand')
|
.from(Tables.brands)
|
||||||
.upsert(brand.toJson())
|
.upsert(brand.toJson())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -47,7 +48,7 @@ class ProductRepository {
|
|||||||
Future<List<ModelModel>> getModelsByBrand(String brandId) async {
|
Future<List<ModelModel>> getModelsByBrand(String brandId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('model')
|
.from(Tables.models)
|
||||||
.select()
|
.select()
|
||||||
.eq('brand_id', brandId)
|
.eq('brand_id', brandId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
@@ -62,7 +63,7 @@ class ProductRepository {
|
|||||||
Future<List<ModelModel>> getModels() async {
|
Future<List<ModelModel>> getModels() async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('model')
|
.from(Tables.models)
|
||||||
.select()
|
.select()
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -77,7 +78,7 @@ class ProductRepository {
|
|||||||
Future<ModelModel> upsertModel(ModelModel model) async {
|
Future<ModelModel> upsertModel(ModelModel model) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('model')
|
.from(Tables.models)
|
||||||
.upsert(model.toJson())
|
.upsert(model.toJson())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -102,7 +103,7 @@ class ProductRepository {
|
|||||||
Future<List<ModelModel>> searchModels(String query) async {
|
Future<List<ModelModel>> searchModels(String query) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('model')
|
.from(Tables.models)
|
||||||
.select()
|
.select()
|
||||||
.ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome
|
.ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||||
import 'package:flux/features/master_data/products/ui/product_dialogs.dart';
|
import 'package:flux/features/master_data/products/ui/product_dialogs.dart';
|
||||||
@@ -63,9 +64,12 @@ class ModelsList extends StatelessWidget {
|
|||||||
: Icons.visibility_off_outlined,
|
: Icons.visibility_off_outlined,
|
||||||
color: model.isActive ? context.accent : Colors.grey,
|
color: model.isActive ? context.accent : Colors.grey,
|
||||||
),
|
),
|
||||||
onPressed: () => context
|
onPressed: () =>
|
||||||
.read<ProductsCubit>()
|
context.read<ProductsCubit>().toggleStatus(
|
||||||
.toggleStatus('model', model.id!, model.isActive),
|
Tables.models,
|
||||||
|
model.id!,
|
||||||
|
model.isActive,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,190 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
|
||||||
|
import '../models/provider_model.dart';
|
||||||
|
import '../models/provider_role.dart';
|
||||||
|
import '../models/provider_location_model.dart';
|
||||||
|
import '../data/provider_repository.dart';
|
||||||
|
part 'provider_form_state.dart';
|
||||||
|
|
||||||
|
class ProviderFormCubit extends Cubit<ProviderFormState> {
|
||||||
|
final ProviderRepository _repository = GetIt.I.get<ProviderRepository>();
|
||||||
|
final _client = Supabase.instance.client; // Lo usiamo al volo per gli store
|
||||||
|
|
||||||
|
ProviderFormCubit()
|
||||||
|
: super(
|
||||||
|
ProviderFormState(
|
||||||
|
provider: ProviderModel.empty(
|
||||||
|
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- INIZIALIZZAZIONE ---
|
||||||
|
Future<void> initForm({
|
||||||
|
required String companyId,
|
||||||
|
ProviderModel? existingProvider,
|
||||||
|
}) async {
|
||||||
|
emit(state.copyWith(status: ProviderFormStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Scarichiamo tutti i negozi dell'azienda
|
||||||
|
final storesResponse = await _client
|
||||||
|
.from('store')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('company_id', companyId);
|
||||||
|
|
||||||
|
// 2. Se stiamo modificando, carichiamo gli store collegati
|
||||||
|
List<String> linkedStoreIds = [];
|
||||||
|
if (existingProvider != null && existingProvider.id != null) {
|
||||||
|
// ... (Vecchio codice di recupero)
|
||||||
|
final links = await _client
|
||||||
|
.from('providers_in_stores')
|
||||||
|
.select('store_id')
|
||||||
|
.eq('provider_id', existingProvider.id!);
|
||||||
|
linkedStoreIds = (links as List)
|
||||||
|
.map((l) => l['store_id'] as String)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
// --- IL TOCCO NINJA: AUTO-SELEZIONE ---
|
||||||
|
// Se stiamo creando un nuovo fornitore e c'è 1 solo negozio in tutto il DB, accendilo!
|
||||||
|
if ((storesResponse as List).length == 1) {
|
||||||
|
linkedStoreIds.add(storesResponse.first['id'] as String);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderFormStatus.initial,
|
||||||
|
provider:
|
||||||
|
existingProvider ?? ProviderModel.empty(companyId: companyId),
|
||||||
|
availableStores: storesResponse as List<dynamic>,
|
||||||
|
selectedStoreIds: linkedStoreIds,
|
||||||
|
localLocations: existingProvider?.locations ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderFormStatus.failure,
|
||||||
|
errorMessage: 'Errore durante l\'inizializzazione: $e',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AGGIORNAMENTO CAMPI ---
|
||||||
|
void updateFields({
|
||||||
|
String? name,
|
||||||
|
String? businessName,
|
||||||
|
String? vatNumber,
|
||||||
|
String? fiscalCode,
|
||||||
|
String? sdiCode,
|
||||||
|
String? emailPec,
|
||||||
|
}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
provider: state.provider.copyWith(
|
||||||
|
name: name,
|
||||||
|
businessName: businessName,
|
||||||
|
vatNumber: vatNumber,
|
||||||
|
fiscalCode: fiscalCode,
|
||||||
|
sdiCode: sdiCode,
|
||||||
|
emailPec: emailPec,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTIONE RUOLI (CHIPS) ---
|
||||||
|
void toggleRole(ProviderRole role) {
|
||||||
|
final currentRoles = List<ProviderRole>.from(state.provider.roles);
|
||||||
|
if (currentRoles.contains(role)) {
|
||||||
|
currentRoles.remove(role);
|
||||||
|
} else {
|
||||||
|
currentRoles.add(role);
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(provider: state.provider.copyWith(roles: currentRoles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTIONE NEGOZI ABILITATI (CHECKBOX) ---
|
||||||
|
void toggleStore(String storeId) {
|
||||||
|
final currentStoreIds = List<String>.from(state.selectedStoreIds);
|
||||||
|
if (currentStoreIds.contains(storeId)) {
|
||||||
|
currentStoreIds.remove(storeId);
|
||||||
|
} else {
|
||||||
|
currentStoreIds.add(storeId);
|
||||||
|
}
|
||||||
|
emit(state.copyWith(selectedStoreIds: currentStoreIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addLocationLocal(ProviderLocationModel location) async {
|
||||||
|
final currentLocations = List<ProviderLocationModel>.from(
|
||||||
|
state.localLocations,
|
||||||
|
);
|
||||||
|
currentLocations.add(location);
|
||||||
|
emit(state.copyWith(localLocations: currentLocations));
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeLocationLocal(int index) {
|
||||||
|
final currentLocations = List<ProviderLocationModel>.from(
|
||||||
|
state.localLocations,
|
||||||
|
);
|
||||||
|
if (index >= 0 && index < currentLocations.length) {
|
||||||
|
currentLocations.removeAt(index);
|
||||||
|
emit(state.copyWith(localLocations: currentLocations));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SALVATAGGIO FINALE ---
|
||||||
|
Future<void> save() async {
|
||||||
|
// Sicurezza di base
|
||||||
|
if (state.provider.name.trim().isEmpty) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderFormStatus.failure,
|
||||||
|
errorMessage: 'Il nome è obbligatorio',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(status: ProviderFormStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Passiamo provider e storeId al repository che farà la magia
|
||||||
|
final savedProvider = await _repository.saveProvider(
|
||||||
|
state.provider,
|
||||||
|
state.selectedStoreIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.localLocations.isNotEmpty) {
|
||||||
|
for (var loc in state.localLocations) {
|
||||||
|
final locToSave = loc.copyWith(
|
||||||
|
providerId: savedProvider.id!,
|
||||||
|
companyId: savedProvider.companyId,
|
||||||
|
);
|
||||||
|
await _repository.saveLocation(locToSave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderFormStatus.success,
|
||||||
|
provider: savedProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderFormStatus.failure,
|
||||||
|
errorMessage: 'Errore di salvataggio: $e',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
part of 'provider_form_cubit.dart';
|
||||||
|
|
||||||
|
// Importa il tuo StoreModel se lo hai
|
||||||
|
|
||||||
|
enum ProviderFormStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class ProviderFormState extends Equatable {
|
||||||
|
final ProviderFormStatus status;
|
||||||
|
final ProviderModel provider;
|
||||||
|
|
||||||
|
// Dati di supporto per l'interfaccia
|
||||||
|
final List<dynamic>
|
||||||
|
availableStores; // Metti List<StoreModel> se hai il modello
|
||||||
|
final List<String> selectedStoreIds; // IDs dei negozi in cui è attivo
|
||||||
|
final List<ProviderLocationModel>
|
||||||
|
localLocations; // Sedi aggiunte prima del salvataggio
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const ProviderFormState({
|
||||||
|
this.status = ProviderFormStatus.initial,
|
||||||
|
required this.provider,
|
||||||
|
this.availableStores = const [],
|
||||||
|
this.selectedStoreIds = const [],
|
||||||
|
this.localLocations = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
ProviderFormState copyWith({
|
||||||
|
ProviderFormStatus? status,
|
||||||
|
ProviderModel? provider,
|
||||||
|
List<dynamic>? availableStores,
|
||||||
|
List<String>? selectedStoreIds,
|
||||||
|
List<ProviderLocationModel>? localLocations,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return ProviderFormState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
provider: provider ?? this.provider,
|
||||||
|
availableStores: availableStores ?? this.availableStores,
|
||||||
|
selectedStoreIds: selectedStoreIds ?? this.selectedStoreIds,
|
||||||
|
localLocations: localLocations ?? this.localLocations,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
status,
|
||||||
|
provider,
|
||||||
|
availableStores,
|
||||||
|
selectedStoreIds,
|
||||||
|
localLocations,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import '../models/provider_model.dart';
|
||||||
|
import '../data/provider_repository.dart';
|
||||||
|
|
||||||
|
part 'provider_list_state.dart';
|
||||||
|
|
||||||
|
class ProviderListCubit extends Cubit<ProviderListState> {
|
||||||
|
final ProviderRepository _repository = GetIt.I.get<ProviderRepository>();
|
||||||
|
|
||||||
|
ProviderListCubit() : super(const ProviderListState());
|
||||||
|
|
||||||
|
Future<void> loadProviders(String storeId) async {
|
||||||
|
emit(state.copyWith(status: ProviderListStatus.loading));
|
||||||
|
try {
|
||||||
|
final providers = await _repository.getProvidersByStore(storeId);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderListStatus.success,
|
||||||
|
providers: providers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadAllProviders() async {
|
||||||
|
emit(state.copyWith(status: ProviderListStatus.loading));
|
||||||
|
try {
|
||||||
|
final allProviders = await _repository.getAllCompanyProviders();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderListStatus.success,
|
||||||
|
allProviders: allProviders,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: ProviderListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
part of 'provider_list_cubit.dart';
|
||||||
|
|
||||||
|
enum ProviderListStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class ProviderListState extends Equatable {
|
||||||
|
final ProviderListStatus status;
|
||||||
|
final List<ProviderModel> providers;
|
||||||
|
final List<ProviderModel> allProviders;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const ProviderListState({
|
||||||
|
this.status = ProviderListStatus.initial,
|
||||||
|
this.providers = const [],
|
||||||
|
this.allProviders = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
ProviderListState copyWith({
|
||||||
|
ProviderListStatus? status,
|
||||||
|
List<ProviderModel>? providers,
|
||||||
|
List<ProviderModel>? allProviders,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return ProviderListState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
providers: providers ?? this.providers,
|
||||||
|
allProviders: allProviders ?? this.allProviders,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, providers, allProviders, errorMessage];
|
||||||
|
}
|
||||||
@@ -1,137 +1,88 @@
|
|||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/provider_model.dart';
|
import '../models/provider_model.dart';
|
||||||
|
import '../models/provider_location_model.dart';
|
||||||
|
|
||||||
class ProviderRepository {
|
class ProviderRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
final _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
|
||||||
// --- ASSOCIAZIONE PROVIDER <-> STORE ---
|
// 1. Carica i provider abilitati per uno specifico Store
|
||||||
|
Future<List<ProviderModel>> getProvidersByStore(String storeId) async {
|
||||||
// Aggiunge un provider a un negozio (Attiva mandato)
|
|
||||||
Future<void> associateProviderToStore({
|
|
||||||
required String providerId,
|
|
||||||
required String storeId,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
await _supabase.from('providers_in_stores').insert({
|
|
||||||
'provider_id': providerId,
|
|
||||||
'store_id': storeId,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore durante l\'associazione provider: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rimuove un provider da un negozio (Disattiva mandato)
|
|
||||||
Future<void> disassociateProviderFromStore({
|
|
||||||
required String providerId,
|
|
||||||
required String storeId,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
await _supabase
|
|
||||||
.from('providers_in_stores')
|
|
||||||
.delete()
|
|
||||||
.eq('provider_id', providerId)
|
|
||||||
.eq('store_id', storeId);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore durante la disassociazione provider: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recupera tutti i provider di una company (per la lista generale)
|
|
||||||
Future<List<ProviderModel>> fetchAllCompanyProviders(String companyId) async {
|
|
||||||
try {
|
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('provider')
|
.from(Tables.providersInStores)
|
||||||
.select('''
|
.select('''
|
||||||
|
provider_id,
|
||||||
|
provider:${Tables.providers} (
|
||||||
*,
|
*,
|
||||||
associated_stores:providers_in_stores (
|
${Tables.providerLocations} (*)
|
||||||
store (
|
|
||||||
*
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
.eq('company_id', companyId)
|
.eq('store_id', storeId)
|
||||||
|
.order('name', referencedTable: 'provider');
|
||||||
|
|
||||||
|
// Mappiamo i risultati estraendo l'oggetto 'provider' annidato
|
||||||
|
return (response as List).map((row) {
|
||||||
|
return ProviderModel.fromMap(row['provider'] as Map<String, dynamic>);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Carica TUTTI i provider della Company (per la gestione anagrafica)
|
||||||
|
Future<List<ProviderModel>> getAllCompanyProviders() async {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.providers)
|
||||||
|
.select('*, ${Tables.providerLocations} (*)')
|
||||||
.order('name');
|
.order('name');
|
||||||
|
|
||||||
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
|
|
||||||
} catch (e) {
|
|
||||||
throw 'Errore fetch providers: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recupera gli ID dei provider associati a uno store (utile per le checkbox)
|
|
||||||
Future<List<String>> fetchAssociatedProviderIds(String storeId) async {
|
|
||||||
try {
|
|
||||||
final response = await _supabase
|
|
||||||
.from('providers_in_stores')
|
|
||||||
.select('provider_id')
|
|
||||||
.eq('store_id', storeId);
|
|
||||||
|
|
||||||
return (response as List)
|
return (response as List)
|
||||||
.map((item) => item['provider_id'].toString())
|
.map((row) => ProviderModel.fromMap(row as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore recupero ID associati: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FUNZIONI STANDARD ---
|
// 3. Salvataggio atomico (Upsert) del Provider
|
||||||
|
Future<ProviderModel> saveProvider(
|
||||||
// Questa la userai nel Form Servizi: carica solo i provider abilitati per lo store
|
ProviderModel provider,
|
||||||
Future<List<ProviderModel>> fetchActiveProvidersForStore(
|
List<String> enabledStoreIds,
|
||||||
String storeId,
|
|
||||||
) async {
|
) async {
|
||||||
try {
|
// A. Salva/Aggiorna il Provider principale
|
||||||
final response = await _supabase
|
final providerWithCompany = provider.copyWith(companyId: _companyId);
|
||||||
.from('provider')
|
final savedRow = await _supabase
|
||||||
.select('*, providers_in_stores!inner(store_id)')
|
.from(Tables.providers)
|
||||||
.eq('providers_in_stores.store_id', storeId)
|
.upsert(providerWithCompany.toMap())
|
||||||
.eq('is_active', true);
|
|
||||||
|
|
||||||
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore fetch provider attivi: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Salva o aggiorna l'anagrafica del Provider
|
|
||||||
Future<ProviderModel> saveProvider(ProviderModel provider) async {
|
|
||||||
try {
|
|
||||||
// .select().single() è fondamentale per farsi restituire
|
|
||||||
// l'oggetto appena creato/aggiornato con l'ID
|
|
||||||
final response = await _supabase
|
|
||||||
.from('provider')
|
|
||||||
.upsert(provider.toMap())
|
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
return ProviderModel.fromMap(response); // <--- DEVE ESSERCI IL RETURN
|
final savedProvider = ProviderModel.fromMap(savedRow);
|
||||||
} catch (e) {
|
|
||||||
rethrow; // <--- Rilancia l'errore al Cubit, non ritornare null!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> syncProviderStores(
|
// B. Sincronizza gli Store (Cancelliamo i vecchi e mettiamo i nuovi per semplicità)
|
||||||
String providerId,
|
// In un'app ad alto traffico faremmo un confronto, qui l'upsert totale è più veloce da scrivere.
|
||||||
List<String> storeIds,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
// 1. Eliminiamo tutte le associazioni correnti per questo provider
|
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('providers_in_stores')
|
.from(Tables.providersInStores)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('provider_id', providerId);
|
.eq('provider_id', savedProvider.id!);
|
||||||
|
|
||||||
// 2. Se ci sono nuovi store da associare, li inseriamo
|
if (enabledStoreIds.isNotEmpty) {
|
||||||
if (storeIds.isNotEmpty) {
|
final storeLinks = enabledStoreIds
|
||||||
final inserts = storeIds
|
.map((sId) => {'provider_id': savedProvider.id, 'store_id': sId})
|
||||||
.map((sId) => {'provider_id': providerId, 'store_id': sId})
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
await _supabase.from('providers_in_stores').insert(inserts);
|
await _supabase.from(Tables.providersInStores).insert(storeLinks);
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore durante la sincronizzazione store: $e');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return savedProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Gestione Sedi (Locations)
|
||||||
|
Future<void> saveLocation(ProviderLocationModel location) async {
|
||||||
|
await _supabase.from(Tables.providerLocations).upsert(location.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteLocation(String locationId) async {
|
||||||
|
await _supabase
|
||||||
|
.from(Tables.providerLocations)
|
||||||
|
.delete()
|
||||||
|
.eq('id', locationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class ProviderLocationModel extends Equatable {
|
||||||
|
final String? id;
|
||||||
|
final String providerId;
|
||||||
|
final String companyId;
|
||||||
|
final String name; // Es: "Laboratorio Centrale"
|
||||||
|
final String address;
|
||||||
|
final String city;
|
||||||
|
final String zipCode;
|
||||||
|
final String province;
|
||||||
|
final String? contactPerson;
|
||||||
|
final bool isMain;
|
||||||
|
|
||||||
|
const ProviderLocationModel({
|
||||||
|
this.id,
|
||||||
|
required this.providerId,
|
||||||
|
required this.companyId,
|
||||||
|
required this.name,
|
||||||
|
required this.address,
|
||||||
|
required this.city,
|
||||||
|
required this.zipCode,
|
||||||
|
required this.province,
|
||||||
|
this.contactPerson,
|
||||||
|
this.isMain = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProviderLocationModel.empty() {
|
||||||
|
return const ProviderLocationModel(
|
||||||
|
providerId: '',
|
||||||
|
companyId: '',
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
zipCode: '',
|
||||||
|
province: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderLocationModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? providerId,
|
||||||
|
String? companyId,
|
||||||
|
String? name,
|
||||||
|
String? address,
|
||||||
|
String? city,
|
||||||
|
String? zipCode,
|
||||||
|
String? province,
|
||||||
|
String? contactPerson,
|
||||||
|
bool? isMain,
|
||||||
|
}) {
|
||||||
|
return ProviderLocationModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
providerId: providerId ?? this.providerId,
|
||||||
|
companyId: companyId ?? this.companyId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
address: address ?? this.address,
|
||||||
|
city: city ?? this.city,
|
||||||
|
zipCode: zipCode ?? this.zipCode,
|
||||||
|
province: province ?? this.province,
|
||||||
|
contactPerson: contactPerson ?? this.contactPerson,
|
||||||
|
isMain: isMain ?? this.isMain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ProviderLocationModel.fromMap(Map<String, dynamic> map) {
|
||||||
|
return ProviderLocationModel(
|
||||||
|
id: map['id'] as String,
|
||||||
|
providerId: map['provider_id'] as String,
|
||||||
|
companyId: map['company_id'] as String,
|
||||||
|
name: map['name'] as String,
|
||||||
|
address: map['address'] as String,
|
||||||
|
city: map['city'] as String,
|
||||||
|
zipCode: map['zip_code'] as String,
|
||||||
|
province: map['province'] as String,
|
||||||
|
contactPerson: map['contact_person'] as String?,
|
||||||
|
isMain: map['is_main'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'provider_id': providerId,
|
||||||
|
'company_id': companyId,
|
||||||
|
'name': name,
|
||||||
|
'address': address,
|
||||||
|
'city': city,
|
||||||
|
'zip_code': zipCode,
|
||||||
|
'province': province,
|
||||||
|
'contact_person': contactPerson,
|
||||||
|
'is_main': isMain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
providerId,
|
||||||
|
companyId,
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
zipCode,
|
||||||
|
province,
|
||||||
|
contactPerson,
|
||||||
|
isMain,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,134 +1,170 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
|
||||||
|
import 'provider_location_model.dart';
|
||||||
|
import 'provider_role.dart';
|
||||||
|
|
||||||
class ProviderModel extends Equatable {
|
class ProviderModel extends Equatable {
|
||||||
final String? id;
|
final String? id;
|
||||||
final String name;
|
|
||||||
final bool landline;
|
|
||||||
final bool mobile;
|
|
||||||
final bool energy;
|
|
||||||
final bool insurance;
|
|
||||||
final bool entertainment;
|
|
||||||
final bool financing;
|
|
||||||
final bool telepass;
|
|
||||||
final bool other;
|
|
||||||
final bool isActive;
|
|
||||||
final String companyId;
|
final String companyId;
|
||||||
final List<StoreModel> associatedStores;
|
final String name; // Nome "commerciale" per riconoscerlo velocemente
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
// Dati fiscali e legali
|
||||||
|
final String? businessName; // Ragione Sociale
|
||||||
|
final String? vatNumber; // P.IVA
|
||||||
|
final String? fiscalCode; // C.F.
|
||||||
|
final String? sdiCode; // Codice Univoco (SDI)
|
||||||
|
final String? emailPec;
|
||||||
|
final String? legalAddress;
|
||||||
|
final String? legalCity;
|
||||||
|
final String? legalZip;
|
||||||
|
final String? legalProvince;
|
||||||
|
|
||||||
|
// Ruoli e Sedi (Relazioni)
|
||||||
|
final List<ProviderRole> roles;
|
||||||
|
final List<ProviderLocationModel>? locations;
|
||||||
|
|
||||||
const ProviderModel({
|
const ProviderModel({
|
||||||
this.id,
|
this.id,
|
||||||
required this.name,
|
|
||||||
required this.landline,
|
|
||||||
required this.mobile,
|
|
||||||
required this.energy,
|
|
||||||
required this.insurance,
|
|
||||||
required this.entertainment,
|
|
||||||
required this.financing,
|
|
||||||
required this.telepass,
|
|
||||||
required this.other,
|
|
||||||
required this.isActive,
|
|
||||||
required this.companyId,
|
required this.companyId,
|
||||||
this.associatedStores = const [],
|
required this.name,
|
||||||
|
this.isActive = true,
|
||||||
|
this.businessName,
|
||||||
|
this.vatNumber,
|
||||||
|
this.fiscalCode,
|
||||||
|
this.sdiCode,
|
||||||
|
this.emailPec,
|
||||||
|
this.legalAddress,
|
||||||
|
this.legalCity,
|
||||||
|
this.legalZip,
|
||||||
|
this.legalProvince,
|
||||||
|
this.roles = const [],
|
||||||
|
this.locations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory ProviderModel.empty({required String companyId}) {
|
||||||
|
return ProviderModel(
|
||||||
|
companyId: companyId,
|
||||||
|
name: '',
|
||||||
|
isActive: true,
|
||||||
|
roles: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? companyId,
|
||||||
|
String? name,
|
||||||
|
bool? isActive,
|
||||||
|
String? businessName,
|
||||||
|
String? vatNumber,
|
||||||
|
String? fiscalCode,
|
||||||
|
String? sdiCode,
|
||||||
|
String? emailPec,
|
||||||
|
String? legalAddress,
|
||||||
|
String? legalCity,
|
||||||
|
String? legalZip,
|
||||||
|
String? legalProvince,
|
||||||
|
List<ProviderRole>? roles,
|
||||||
|
List<ProviderLocationModel>? locations,
|
||||||
|
}) {
|
||||||
|
return ProviderModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
companyId: companyId ?? this.companyId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
businessName: businessName ?? this.businessName,
|
||||||
|
vatNumber: vatNumber ?? this.vatNumber,
|
||||||
|
fiscalCode: fiscalCode ?? this.fiscalCode,
|
||||||
|
sdiCode: sdiCode ?? this.sdiCode,
|
||||||
|
emailPec: emailPec ?? this.emailPec,
|
||||||
|
legalAddress: legalAddress ?? this.legalAddress,
|
||||||
|
legalCity: legalCity ?? this.legalCity,
|
||||||
|
legalZip: legalZip ?? this.legalZip,
|
||||||
|
legalProvince: legalProvince ?? this.legalProvince,
|
||||||
|
roles: roles ?? this.roles,
|
||||||
|
locations: locations ?? this.locations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory ProviderModel.fromMap(Map<String, dynamic> map) {
|
factory ProviderModel.fromMap(Map<String, dynamic> map) {
|
||||||
// Estraiamo la lista dalla pivot e poi prendiamo l'oggetto 'store' annidato
|
// Parsing sicuro dell'array testuale di Supabase per trasformarlo in Enum
|
||||||
final pivotList = map['associated_stores'] as List?;
|
List<ProviderRole> parsedRoles = [];
|
||||||
List<StoreModel> stores = [];
|
if (map['roles'] != null) {
|
||||||
if (pivotList != null) {
|
final List<dynamic> rawRoles = map['roles'];
|
||||||
stores = pivotList
|
for (var r in rawRoles) {
|
||||||
.where((item) => item['store'] != null) // Sicurezza
|
final role = ProviderRole.fromString(r as String);
|
||||||
|
if (role != null) parsedRoles.add(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing della JOIN per le locations, se presenti nella query
|
||||||
|
List<ProviderLocationModel>? parsedLocations;
|
||||||
|
if (map['provider_locations'] != null) {
|
||||||
|
parsedLocations = (map['provider_locations'] as List<dynamic>)
|
||||||
.map(
|
.map(
|
||||||
(item) => StoreModel.fromMap(item['store'] as Map<String, dynamic>),
|
(item) =>
|
||||||
|
ProviderLocationModel.fromMap(item as Map<String, dynamic>),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderModel(
|
return ProviderModel(
|
||||||
id: map['id'],
|
id: map['id'] as String,
|
||||||
name: map['name'],
|
companyId: map['company_id'] as String,
|
||||||
landline: map['landline'] ?? false,
|
name: map['name'] as String,
|
||||||
mobile: map['mobile'] ?? false,
|
isActive: map['is_active'] as bool? ?? true,
|
||||||
energy: map['energy'] ?? false,
|
businessName: map['business_name'] as String?,
|
||||||
insurance: map['insurance'] ?? false,
|
vatNumber: map['vat_number'] as String?,
|
||||||
entertainment: map['entertainment'] ?? false,
|
fiscalCode: map['fiscal_code'] as String?,
|
||||||
financing: map['financing'] ?? false,
|
sdiCode: map['sdi_code'] as String?,
|
||||||
telepass: map['telepass'] ?? false,
|
emailPec: map['email_pec'] as String?,
|
||||||
other: map['other'] ?? false,
|
legalAddress: map['legal_address'] as String?,
|
||||||
isActive: map['is_active'] ?? true,
|
legalCity: map['legal_city'] as String?,
|
||||||
companyId: map['company_id'],
|
legalZip: map['legal_zip'] as String?,
|
||||||
associatedStores: stores,
|
legalProvince: map['legal_province'] as String?,
|
||||||
|
roles: parsedRoles,
|
||||||
|
locations: parsedLocations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
final map = {
|
Map<String, dynamic> baseMap = {
|
||||||
'name': name,
|
if (id != null && id!.trim().isNotEmpty) 'id': id,
|
||||||
'landline': landline,
|
|
||||||
'mobile': mobile,
|
|
||||||
'energy': energy,
|
|
||||||
'insurance': insurance,
|
|
||||||
'entertainment': entertainment,
|
|
||||||
'financing': financing,
|
|
||||||
'telepass': telepass,
|
|
||||||
'other': other,
|
|
||||||
'is_active': isActive,
|
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
|
'name': name,
|
||||||
|
'is_active': isActive,
|
||||||
|
'business_name': businessName,
|
||||||
|
'vat_number': vatNumber,
|
||||||
|
'fiscal_code': fiscalCode,
|
||||||
|
'sdi_code': sdiCode,
|
||||||
|
'email_pec': emailPec,
|
||||||
|
'legal_address': legalAddress,
|
||||||
|
'legal_city': legalCity,
|
||||||
|
'legal_zip': legalZip,
|
||||||
|
'legal_province': legalProvince,
|
||||||
|
// Trasformiamo gli Enum di nuovo in stringhe per Supabase
|
||||||
|
'roles': roles.map((e) => e.name).toList(),
|
||||||
};
|
};
|
||||||
// AGGIUNGIAMO L'ID SOLO SE NON È NULLO
|
return baseMap;
|
||||||
// Senza questo, l'upsert non sa dove andare a parare
|
|
||||||
if (id != null) {
|
|
||||||
map['id'] = id!;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
id,
|
id,
|
||||||
name,
|
|
||||||
landline,
|
|
||||||
mobile,
|
|
||||||
energy,
|
|
||||||
insurance,
|
|
||||||
entertainment,
|
|
||||||
financing,
|
|
||||||
telepass,
|
|
||||||
other,
|
|
||||||
isActive,
|
|
||||||
companyId,
|
companyId,
|
||||||
associatedStores,
|
name,
|
||||||
|
isActive,
|
||||||
|
businessName,
|
||||||
|
vatNumber,
|
||||||
|
fiscalCode,
|
||||||
|
sdiCode,
|
||||||
|
emailPec,
|
||||||
|
legalAddress,
|
||||||
|
legalCity,
|
||||||
|
legalZip,
|
||||||
|
legalProvince,
|
||||||
|
roles,
|
||||||
|
locations,
|
||||||
];
|
];
|
||||||
|
|
||||||
ProviderModel copyWith({
|
|
||||||
String? id,
|
|
||||||
String? name,
|
|
||||||
bool? landline,
|
|
||||||
bool? mobile,
|
|
||||||
bool? energy,
|
|
||||||
bool? insurance,
|
|
||||||
bool? entertainment,
|
|
||||||
bool? financing,
|
|
||||||
bool? telepass,
|
|
||||||
bool? other,
|
|
||||||
bool? isActive,
|
|
||||||
String? companyId,
|
|
||||||
List<StoreModel>? associatedStores,
|
|
||||||
}) {
|
|
||||||
return ProviderModel(
|
|
||||||
id: id ?? this.id,
|
|
||||||
name: name ?? this.name,
|
|
||||||
landline: landline ?? this.landline,
|
|
||||||
mobile: mobile ?? this.mobile,
|
|
||||||
energy: energy ?? this.energy,
|
|
||||||
insurance: insurance ?? this.insurance,
|
|
||||||
entertainment: entertainment ?? this.entertainment,
|
|
||||||
financing: financing ?? this.financing,
|
|
||||||
telepass: telepass ?? this.telepass,
|
|
||||||
other: other ?? this.other,
|
|
||||||
isActive: isActive ?? this.isActive,
|
|
||||||
companyId: companyId ?? this.companyId,
|
|
||||||
associatedStores: associatedStores ?? this.associatedStores,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
lib/features/master_data/providers/models/provider_role.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum ProviderRole {
|
||||||
|
landline('Fisso', Colors.blue),
|
||||||
|
mobile('Mobile', Colors.green),
|
||||||
|
energy('Energia', Colors.orange),
|
||||||
|
insurance('Assicurazioni', Colors.purple),
|
||||||
|
financing('Finanziamenti', Colors.teal),
|
||||||
|
entertainment('Intrattenimento', Colors.red),
|
||||||
|
telepass('Telepass', Colors.amber),
|
||||||
|
repairCenter('Centro Riparazioni', Colors.cyan),
|
||||||
|
partsSupplier('Fornitore Ricambi', Colors.indigo),
|
||||||
|
merchandiseSupplier('Fornitore Merce', Colors.brown);
|
||||||
|
|
||||||
|
final String displayValue;
|
||||||
|
final Color color; // <-- Il nostro tocco magico
|
||||||
|
|
||||||
|
const ProviderRole(this.displayValue, this.color);
|
||||||
|
|
||||||
|
static ProviderRole? fromString(String? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return ProviderRole.values.firstWhere((e) => e.name == value);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
395
lib/features/master_data/providers/ui/provider_form_screen.dart
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/blocs/provider_form_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/models/provider_location_model.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/models/provider_role.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/ui/provider_location_dialog.dart';
|
||||||
|
|
||||||
|
class ProviderFormScreen extends StatefulWidget {
|
||||||
|
final ProviderModel? existingProvider;
|
||||||
|
|
||||||
|
const ProviderFormScreen({super.key, this.existingProvider});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProviderFormScreen> createState() => _ProviderFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderFormScreenState extends State<ProviderFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers per i campi di testo
|
||||||
|
late final TextEditingController _nameCtrl;
|
||||||
|
late final TextEditingController _businessNameCtrl;
|
||||||
|
late final TextEditingController _vatCtrl;
|
||||||
|
late final TextEditingController _cfCtrl;
|
||||||
|
late final TextEditingController _sdiCtrl;
|
||||||
|
late final TextEditingController _pecCtrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final p = widget.existingProvider;
|
||||||
|
|
||||||
|
_nameCtrl = TextEditingController(text: p?.name);
|
||||||
|
_businessNameCtrl = TextEditingController(text: p?.businessName);
|
||||||
|
_vatCtrl = TextEditingController(text: p?.vatNumber);
|
||||||
|
_cfCtrl = TextEditingController(text: p?.fiscalCode);
|
||||||
|
_sdiCtrl = TextEditingController(text: p?.sdiCode);
|
||||||
|
_pecCtrl = TextEditingController(text: p?.emailPec);
|
||||||
|
|
||||||
|
// Inizializziamo il Cubit appena la schermata si apre
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// Recupero il companyId dall'utente loggato (Vigile Urbano)
|
||||||
|
final companyId = context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStore!
|
||||||
|
.companyId;
|
||||||
|
|
||||||
|
context.read<ProviderFormCubit>().initForm(
|
||||||
|
companyId: companyId,
|
||||||
|
existingProvider: widget.existingProvider,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_businessNameCtrl.dispose();
|
||||||
|
_vatCtrl.dispose();
|
||||||
|
_cfCtrl.dispose();
|
||||||
|
_sdiCtrl.dispose();
|
||||||
|
_pecCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flushControllers() {
|
||||||
|
context.read<ProviderFormCubit>().updateFields(
|
||||||
|
name: _nameCtrl.text.trim(),
|
||||||
|
businessName: _businessNameCtrl.text.trim(),
|
||||||
|
vatNumber: _vatCtrl.text.trim(),
|
||||||
|
fiscalCode: _cfCtrl.text.trim(),
|
||||||
|
sdiCode: _sdiCtrl.text.trim(),
|
||||||
|
emailPec: _pecCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isEditing = widget.existingProvider != null;
|
||||||
|
|
||||||
|
return BlocConsumer<ProviderFormCubit, ProviderFormState>(
|
||||||
|
listenWhen: (previous, current) => previous.status != current.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == ProviderFormStatus.success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Fornitore salvato con successo!')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(); // Torna alla lista
|
||||||
|
} else if (state.status == ProviderFormStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == ProviderFormStatus.loading &&
|
||||||
|
state.provider.id == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEditing ? 'Modifica Fornitore' : 'Nuovo Fornitore'),
|
||||||
|
actions: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
_flushControllers();
|
||||||
|
context.read<ProviderFormCubit>().save();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('Salva'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildGeneralCard(context, state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildRolesCard(context, state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildFiscalCard(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildStoresCard(context, state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLocationsCard(context, state),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CARD 1: DATI GENERALI ---
|
||||||
|
Widget _buildGeneralCard(BuildContext context, ProviderFormState state) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Dati Generali',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nome Fornitore (Display Name) *',
|
||||||
|
prefixIcon: Icon(Icons.storefront),
|
||||||
|
),
|
||||||
|
validator: (val) =>
|
||||||
|
val == null || val.isEmpty ? 'Campo obbligatorio' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Volendo qui puoi aggiungere lo Switch per "Attivo/Inattivo"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CARD 2: RUOLI (I CHIPS NINJA) ---
|
||||||
|
Widget _buildRolesCard(BuildContext context, ProviderFormState state) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Ruoli e Servizi',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Seleziona cosa fa questo fornitore (puoi sceglierne più di uno):',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: ProviderRole.values.map((role) {
|
||||||
|
final isSelected = state.provider.roles.contains(role);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(role.displayValue),
|
||||||
|
selected: isSelected,
|
||||||
|
selectedColor: role.color.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: role.color,
|
||||||
|
side: BorderSide(color: role.color.withValues(alpha: 0.3)),
|
||||||
|
onSelected: (bool selected) {
|
||||||
|
context.read<ProviderFormCubit>().toggleRole(role);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CARD 3: DATI FISCALI ---
|
||||||
|
Widget _buildFiscalCard(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Dati Fiscali (Per DDT e Fatturazione)',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _businessNameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ragione Sociale (es. Tech SpA)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _vatCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Partita IVA'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _cfCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Codice Fiscale',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _sdiCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Codice SDI'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _pecCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email PEC'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CARD 4: NEGOZI ABILITATI ---
|
||||||
|
Widget _buildStoresCard(BuildContext context, ProviderFormState state) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Negozi Abilitati',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'In quali punti vendita deve apparire questo fornitore?',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (state.availableStores.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Nessun negozio trovato.',
|
||||||
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...state.availableStores.map((storeMap) {
|
||||||
|
final storeId = storeMap['id'] as String;
|
||||||
|
final storeName = storeMap['name'] as String;
|
||||||
|
final isEnabled = state.selectedStoreIds.contains(storeId);
|
||||||
|
|
||||||
|
return SwitchListTile(
|
||||||
|
title: Text(storeName),
|
||||||
|
value: isEnabled,
|
||||||
|
onChanged: (bool val) {
|
||||||
|
context.read<ProviderFormCubit>().toggleStore(storeId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationsCard(BuildContext context, ProviderFormState state) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Sedi e Laboratori',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () async {
|
||||||
|
ProviderFormCubit providerFormCubit = context
|
||||||
|
.read<ProviderFormCubit>();
|
||||||
|
final res = await showDialog<ProviderLocationModel?>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const ProviderLocationDialog(),
|
||||||
|
);
|
||||||
|
if (res != null) {
|
||||||
|
// Chiama il cubit per aggiungere localmente
|
||||||
|
providerFormCubit.addLocationLocal(res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add_location_alt),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (state.localLocations.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'Nessun indirizzo di spedizione inserito.',
|
||||||
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: state.localLocations.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loc = state.localLocations[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
loc.isMain ? Icons.star : Icons.location_on,
|
||||||
|
color: loc.isMain ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
title: Text(loc.name),
|
||||||
|
subtitle: Text(
|
||||||
|
'${loc.address}, ${loc.city} (${loc.province})',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
onPressed: () => context
|
||||||
|
.read<ProviderFormCubit>()
|
||||||
|
.removeLocationLocal(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
|
||||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
|
||||||
|
|
||||||
class ProviderFormSheet extends StatefulWidget {
|
|
||||||
final ProviderModel? initialProvider;
|
|
||||||
|
|
||||||
const ProviderFormSheet({super.key, this.initialProvider});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ProviderFormSheet> createState() => _ProviderFormSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
|
||||||
late TextEditingController _nameController;
|
|
||||||
late bool _landline;
|
|
||||||
late bool _mobile;
|
|
||||||
late bool _energy;
|
|
||||||
late bool _insurance;
|
|
||||||
late bool _entertainment;
|
|
||||||
late bool _financing;
|
|
||||||
late bool _telepass;
|
|
||||||
late bool _other;
|
|
||||||
late bool _isActive;
|
|
||||||
final List<String> _tempSelectedStoreIds =
|
|
||||||
[]; // Per gestire la selezione temporanea dei negozi
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final p = widget.initialProvider;
|
|
||||||
for (final store in p?.associatedStores ?? []) {
|
|
||||||
_tempSelectedStoreIds.add(store.id!);
|
|
||||||
}
|
|
||||||
_nameController = TextEditingController(text: p?.name ?? '');
|
|
||||||
_landline = p?.landline ?? false;
|
|
||||||
_mobile = p?.mobile ?? false;
|
|
||||||
_energy = p?.energy ?? false;
|
|
||||||
_insurance = p?.insurance ?? false;
|
|
||||||
_entertainment = p?.entertainment ?? false;
|
|
||||||
_financing = p?.financing ?? false;
|
|
||||||
_telepass = p?.telepass ?? false;
|
|
||||||
_other = p?.other ?? false;
|
|
||||||
_isActive = p?.isActive ?? true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _save() {
|
|
||||||
if (_nameController.text.trim().isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final cubit = context.read<ProvidersCubit>();
|
|
||||||
final provider = ProviderModel(
|
|
||||||
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
|
|
||||||
name: _nameController.text.trim(),
|
|
||||||
landline: _landline,
|
|
||||||
mobile: _mobile,
|
|
||||||
energy: _energy,
|
|
||||||
insurance: _insurance,
|
|
||||||
entertainment: _entertainment,
|
|
||||||
financing: _financing,
|
|
||||||
telepass: _telepass,
|
|
||||||
other: _other,
|
|
||||||
isActive: _isActive,
|
|
||||||
companyId:
|
|
||||||
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
|
|
||||||
);
|
|
||||||
cubit.saveProvider(provider, _tempSelectedStoreIds);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(
|
|
||||||
context,
|
|
||||||
).viewInsets.bottom, // Gestisce la tastiera
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
widget.initialProvider == null
|
|
||||||
? "Nuovo Provider"
|
|
||||||
: "Modifica Provider",
|
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: _nameController,
|
|
||||||
keyboardType: TextInputType.name,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Nome Gestore/Brand",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
"Servizi Abilitati",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Energia (Luce/Gas)",
|
|
||||||
_energy,
|
|
||||||
(v) => setState(() => _energy = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Telefonia Fissa",
|
|
||||||
_landline,
|
|
||||||
(v) => setState(() => _landline = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Telefonia Mobile",
|
|
||||||
_mobile,
|
|
||||||
(v) => setState(() => _mobile = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Assicurazioni",
|
|
||||||
_insurance,
|
|
||||||
(v) => setState(() => _insurance = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Intrattenimento",
|
|
||||||
_entertainment,
|
|
||||||
(v) => setState(() => _entertainment = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Finanziamenti",
|
|
||||||
_financing,
|
|
||||||
(v) => setState(() => _financing = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Telepass",
|
|
||||||
_telepass,
|
|
||||||
(v) => setState(() => _telepass = v),
|
|
||||||
),
|
|
||||||
_buildSwitch(
|
|
||||||
"Altro/Accessori",
|
|
||||||
_other,
|
|
||||||
(v) => setState(() => _other = v),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildSwitch(
|
|
||||||
"Stato Attivo",
|
|
||||||
_isActive,
|
|
||||||
(v) => setState(() => _isActive = v),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
const Text(
|
|
||||||
"Abilita nei Negozi",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Qui usiamo un BlocBuilder per prendere la lista di tutti i negozi della company
|
|
||||||
BlocBuilder<StoreCubit, StoreState>(
|
|
||||||
builder: (context, storeState) {
|
|
||||||
return Column(
|
|
||||||
children: storeState.stores.map((store) {
|
|
||||||
final isAssociated = _tempSelectedStoreIds.contains(
|
|
||||||
store.id,
|
|
||||||
);
|
|
||||||
return CheckboxListTile(
|
|
||||||
title: Text(store.name),
|
|
||||||
value: isAssociated,
|
|
||||||
onChanged: (val) {
|
|
||||||
setState(() {
|
|
||||||
if (val == true) {
|
|
||||||
_tempSelectedStoreIds.add(store.id!);
|
|
||||||
} else {
|
|
||||||
_tempSelectedStoreIds.remove(store.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(50),
|
|
||||||
),
|
|
||||||
onPressed: _save,
|
|
||||||
child: const Text("SALVA ANAGRAFICA"),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSwitch(String title, bool value, Function(bool) onChanged) {
|
|
||||||
return SwitchListTile(
|
|
||||||
title: Text(title),
|
|
||||||
value: value,
|
|
||||||
onChanged: onChanged,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
227
lib/features/master_data/providers/ui/provider_list_screen.dart
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/routes/routes.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/models/provider_role.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class ProviderListScreen extends StatefulWidget {
|
||||||
|
const ProviderListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProviderListScreen> createState() => _ProviderListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderListScreenState extends State<ProviderListScreen> {
|
||||||
|
// Filtro attivo (null = tutti)
|
||||||
|
ProviderRole? _selectedFilter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Chiamiamo il refresh quando entriamo (il currentStore serve per caricare quelli giusti)
|
||||||
|
final storeId = context.read<SessionCubit>().state.currentStore?.id;
|
||||||
|
if (storeId != null) {
|
||||||
|
context.read<ProviderListCubit>().loadProviders(storeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDesktop = MediaQuery.of(context).size.width > 800;
|
||||||
|
|
||||||
|
// --- COSTRUIAMO I CHIP DEI FILTRI CON I COLORI ---
|
||||||
|
final filterChipsWidgets = [
|
||||||
|
FilterChip(
|
||||||
|
label: const Text(
|
||||||
|
'Tutti',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
selected: _selectedFilter == null,
|
||||||
|
onSelected: (val) => setState(() => _selectedFilter = null),
|
||||||
|
),
|
||||||
|
...ProviderRole.values.map((role) {
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(role.displayValue),
|
||||||
|
selected: _selectedFilter == role,
|
||||||
|
// Un po' di trasparenza al colore selezionato per non accecare
|
||||||
|
selectedColor: role.color.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: role.color,
|
||||||
|
// Bordo leggermente colorato per dare un hint visuale anche da spento
|
||||||
|
side: BorderSide(color: role.color.withValues(alpha: 0.3)),
|
||||||
|
onSelected: (val) {
|
||||||
|
setState(() => _selectedFilter = val ? role : null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Gestione Fornitori')),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () async {
|
||||||
|
final providerListCubit = context.read<ProviderListCubit>();
|
||||||
|
final storeId = context.read<SessionCubit>().state.currentStore?.id;
|
||||||
|
await context.pushNamed(Routes.providerForm);
|
||||||
|
if (!mounted || storeId == null) return;
|
||||||
|
providerListCubit.loadProviders(storeId);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Nuovo Fornitore'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// --- BARRA DEI FILTRI INTELLIGENTE ---
|
||||||
|
if (isDesktop)
|
||||||
|
// Desktop: Wrap multilinea con un bel padding
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: filterChipsWidgets,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
// Mobile: Scorrimento orizzontale compatto
|
||||||
|
SizedBox(
|
||||||
|
height: 60,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
itemCount: filterChipsWidgets.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) => filterChipsWidgets[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// --- LISTA FORNITORI ---
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<ProviderListCubit, ProviderListState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == ProviderListStatus.loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == ProviderListStatus.failure) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Errore: ${state.errorMessage}',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayList = _selectedFilter == null
|
||||||
|
? state.providers
|
||||||
|
: state.providers
|
||||||
|
.where((p) => p.roles.contains(_selectedFilter))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (displayList.isEmpty) {
|
||||||
|
return const Center(child: Text('Nessun fornitore trovato.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: displayList.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final provider = displayList[index];
|
||||||
|
|
||||||
|
// --- I CHIP COLORATI DELLA LISTA ---
|
||||||
|
final roleChips = Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: provider.roles
|
||||||
|
.map(
|
||||||
|
(r) => Chip(
|
||||||
|
label: Text(
|
||||||
|
r.displayValue,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: r.color.withValues(
|
||||||
|
alpha: 0.9,
|
||||||
|
), // Testo colorato!
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: r.color.withValues(
|
||||||
|
alpha: 0.1,
|
||||||
|
), // Sfondo pastello
|
||||||
|
side: BorderSide(
|
||||||
|
color: r.color.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
provider.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: provider.isActive ? null : Colors.grey,
|
||||||
|
decoration: provider.isActive
|
||||||
|
? null
|
||||||
|
: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isDesktop) ...[
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: roleChips),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: isDesktop
|
||||||
|
? null
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: roleChips,
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
await context.pushNamed(
|
||||||
|
Routes.providerForm,
|
||||||
|
extra: provider,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
final storeId = context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStore
|
||||||
|
?.id;
|
||||||
|
if (storeId != null) {
|
||||||
|
context.read<ProviderListCubit>().loadProviders(
|
||||||
|
storeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/provider_location_model.dart';
|
||||||
|
|
||||||
|
class ProviderLocationDialog extends StatefulWidget {
|
||||||
|
final ProviderLocationModel? initialLocation;
|
||||||
|
|
||||||
|
const ProviderLocationDialog({super.key, this.initialLocation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProviderLocationDialog> createState() => _ProviderLocationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderLocationDialogState extends State<ProviderLocationDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _nameCtrl;
|
||||||
|
late final TextEditingController _addressCtrl;
|
||||||
|
late final TextEditingController _cityCtrl;
|
||||||
|
late final TextEditingController _zipCtrl;
|
||||||
|
late final TextEditingController _provCtrl;
|
||||||
|
late final TextEditingController _contactCtrl;
|
||||||
|
bool _isMain = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final l = widget.initialLocation;
|
||||||
|
_nameCtrl = TextEditingController(text: l?.name);
|
||||||
|
_addressCtrl = TextEditingController(text: l?.address);
|
||||||
|
_cityCtrl = TextEditingController(text: l?.city);
|
||||||
|
_zipCtrl = TextEditingController(text: l?.zipCode);
|
||||||
|
_provCtrl = TextEditingController(text: l?.province);
|
||||||
|
_contactCtrl = TextEditingController(text: l?.contactPerson);
|
||||||
|
_isMain = l?.isMain ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
widget.initialLocation == null
|
||||||
|
? 'Aggiungi Sede/Laboratorio'
|
||||||
|
: 'Modifica Sede',
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nome Sede (es. Laboratorio Sud) *',
|
||||||
|
),
|
||||||
|
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _addressCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Indirizzo *'),
|
||||||
|
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _cityCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Città *'),
|
||||||
|
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _provCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Prov.'),
|
||||||
|
maxLength: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _zipCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'CAP *'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _contactCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Referente (opzionale)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Sede Principale'),
|
||||||
|
value: _isMain,
|
||||||
|
onChanged: (v) => setState(() => _isMain = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annulla'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
ProviderLocationModel newLocation = ProviderLocationModel(
|
||||||
|
id: widget.initialLocation?.id,
|
||||||
|
name: _nameCtrl.text.trim(),
|
||||||
|
address: _addressCtrl.text.trim(),
|
||||||
|
city: _cityCtrl.text.trim(),
|
||||||
|
zipCode: _zipCtrl.text.trim(),
|
||||||
|
province: _provCtrl.text.trim().toUpperCase(),
|
||||||
|
contactPerson: _contactCtrl.text.trim(),
|
||||||
|
isMain: _isMain,
|
||||||
|
companyId: widget.initialLocation?.companyId ?? '',
|
||||||
|
providerId: widget.initialLocation?.providerId ?? '',
|
||||||
|
);
|
||||||
|
// Restituiamo una mappa o un modello parziale (senza ID e FK che gestirà il Cubit)
|
||||||
|
Navigator.pop(context, newLocation);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Conferma'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
|
||||||
import 'package:flux/features/master_data/providers/ui/provider_form_sheet.dart';
|
|
||||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
|
||||||
|
|
||||||
class ProvidersMasterDataScreen extends StatefulWidget {
|
|
||||||
const ProvidersMasterDataScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ProvidersMasterDataScreen> createState() =>
|
|
||||||
_ProvidersMasterDataScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text("Anagrafica Provider")),
|
|
||||||
body: BlocBuilder<ProvidersCubit, ProvidersState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.isLoading && state.allProviders.isEmpty) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.allProviders.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Un'icona grande e stilizzata
|
|
||||||
Icon(
|
|
||||||
Icons.handshake_outlined,
|
|
||||||
size: 80,
|
|
||||||
color: Colors.indigo.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Text(
|
|
||||||
"Nessun Provider configurato",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text(
|
|
||||||
"Aggiungi i partner con cui collabori (es. Enel, WindTre, ecc.) per poter gestire i servizi e i mandati nei tuoi negozi.",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Un bel bottone centrato per chi non vuole usare il FAB in basso
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => _showProviderForm(context, null),
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("AGGIUNGI IL PRIMO PROVIDER"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
|
||||||
itemCount: state.allProviders.length,
|
|
||||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final provider = state.allProviders[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: provider.isActive
|
|
||||||
? Colors.green.shade100
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
child: Icon(
|
|
||||||
Icons.business,
|
|
||||||
color: provider.isActive ? Colors.green : Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
provider.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: _buildCardSubtitle(
|
|
||||||
provider,
|
|
||||||
), // Una funzione che costruisce il sottotitolo con i badge
|
|
||||||
trailing: const Icon(Icons.edit_outlined),
|
|
||||||
onTap: () => _showProviderForm(context, provider),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () => _showProviderForm(context, null),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCardSubtitle(ProviderModel provider) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildProviderBadges(provider), // I badge che abbiamo fatto prima
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
// Un piccolo testo che indica il numero di store associati
|
|
||||||
// Nota: Dovrai assicurarti che il Cubit carichi queste info
|
|
||||||
return Text(
|
|
||||||
"Disponibile in ${provider.associatedStores.length} negozi",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.indigo.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visualizza i servizi abilitati per quel provider nella lista
|
|
||||||
Widget _buildProviderBadges(ProviderModel p) {
|
|
||||||
return Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
children: [
|
|
||||||
if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
|
|
||||||
if (p.energy) _smallTag("⚡ Energy", Colors.orange),
|
|
||||||
if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
|
|
||||||
if (p.entertainment) _smallTag("📺 Ent", Colors.red),
|
|
||||||
if (p.financing) _smallTag("💰 Fin", Colors.purple),
|
|
||||||
if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
|
|
||||||
if (p.other) _smallTag("📦 Altro", Colors.grey),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _smallTag(String label, Color color) {
|
|
||||||
return Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DIALOG PER INSERIMENTO/MODIFICA
|
|
||||||
void _showProviderForm(BuildContext context, ProviderModel? provider) {
|
|
||||||
final providersCubit = context.read<ProvidersCubit>();
|
|
||||||
final storeCubit = context.read<StoreCubit>();
|
|
||||||
// Implementeremo qui il form con i vari SwitchListTile
|
|
||||||
// Per ora facciamo un segnaposto o passiamo a scriverlo seriamente
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (modalContext) => MultiBlocProvider(
|
|
||||||
providers: [
|
|
||||||
BlocProvider.value(value: providersCubit),
|
|
||||||
BlocProvider.value(value: storeCubit),
|
|
||||||
],
|
|
||||||
child: ProviderFormSheet(initialProvider: provider),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -11,7 +12,7 @@ class StaffRepository {
|
|||||||
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
|
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
|
||||||
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_member')
|
.from(Tables.staffMembers)
|
||||||
.select()
|
.select()
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.order('name', ascending: true);
|
.order('name', ascending: true);
|
||||||
@@ -19,9 +20,22 @@ class StaffRepository {
|
|||||||
return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList();
|
return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<StaffMemberModel?> getStaffMemberById(String staffId) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.staffMembers)
|
||||||
|
.select()
|
||||||
|
.eq('id', staffId)
|
||||||
|
.single();
|
||||||
|
return StaffMemberModel.fromMap(response);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
throw ('Errore nel recupero del membro staff con ID $staffId: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<StaffMemberModel> saveStaffMember(StaffMemberModel member) async {
|
Future<StaffMemberModel> saveStaffMember(StaffMemberModel member) async {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_member')
|
.from(Tables.staffMembers)
|
||||||
.upsert(member.toMap())
|
.upsert(member.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -64,7 +78,7 @@ class StaffRepository {
|
|||||||
try {
|
try {
|
||||||
await _supabase.auth.resetPasswordForEmail(
|
await _supabase.auth.resetPasswordForEmail(
|
||||||
email,
|
email,
|
||||||
redirectTo: 'https://flux-web-invite.marco-6ba.workers.dev/',
|
redirectTo: resetPasswordUrl,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Errore nell'invio del link: $e");
|
throw Exception("Errore nell'invio del link: $e");
|
||||||
@@ -77,14 +91,14 @@ class StaffRepository {
|
|||||||
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
|
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
|
||||||
Future<List<StaffMemberModel>> getStaffMembersInStore(String storeId) async {
|
Future<List<StaffMemberModel>> getStaffMembersInStore(String storeId) async {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_in_stores')
|
.from(Tables.staffInStores)
|
||||||
.select(
|
.select(
|
||||||
'staff_member (*)',
|
'${Tables.staffMembers} (*)',
|
||||||
) // Prende tutti i campi della tabella staff_member collegata
|
) // Prende tutti i campi della tabella staff_member collegata
|
||||||
.eq('store_id', storeId);
|
.eq('store_id', storeId);
|
||||||
|
|
||||||
return (response as List)
|
return (response as List)
|
||||||
.map((item) => StaffMemberModel.fromMap(item['staff_member']))
|
.map((item) => StaffMemberModel.fromMap(item[Tables.staffMembers]))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,20 +106,20 @@ class StaffRepository {
|
|||||||
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
|
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
|
||||||
Future<List<StoreModel>> getStaffMemberStore(String staffId) async {
|
Future<List<StoreModel>> getStaffMemberStore(String staffId) async {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('staff_in_stores')
|
.from(Tables.staffInStores)
|
||||||
.select(
|
.select(
|
||||||
'store (*)',
|
'${Tables.stores} (*)',
|
||||||
) // Prende tutti i campi della tabella store collegata
|
) // Prende tutti i campi della tabella store collegata
|
||||||
.eq('staff_member_id', staffId);
|
.eq('staff_member_id', staffId);
|
||||||
|
|
||||||
return (response as List)
|
return (response as List)
|
||||||
.map((item) => StoreModel.fromMap(item['store']))
|
.map((item) => StoreModel.fromMap(item[Tables.stores]))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assegna un membro a un negozio
|
// Assegna un membro a un negozio
|
||||||
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
||||||
await _supabase.from('staff_in_stores').insert({
|
await _supabase.from(Tables.staffInStores).insert({
|
||||||
'staff_member_id': staffId,
|
'staff_member_id': staffId,
|
||||||
'store_id': storeId,
|
'store_id': storeId,
|
||||||
});
|
});
|
||||||
@@ -114,7 +128,7 @@ class StaffRepository {
|
|||||||
// Rimuove l'assegnazione
|
// Rimuove l'assegnazione
|
||||||
Future<void> removeStaffFromStore(String staffId, String storeId) async {
|
Future<void> removeStaffFromStore(String staffId, String storeId) async {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('staff_in_stores')
|
.from(Tables.staffInStores)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('staff_member_id', staffId)
|
.eq('staff_member_id', staffId)
|
||||||
.eq('store_id', storeId);
|
.eq('store_id', storeId);
|
||||||
@@ -125,7 +139,7 @@ class StaffRepository {
|
|||||||
// Utility per pulire le assegnazioni esistenti prima di riscriverle
|
// Utility per pulire le assegnazioni esistenti prima di riscriverle
|
||||||
Future<void> clearStoreAssignments(String staffId) async {
|
Future<void> clearStoreAssignments(String staffId) async {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('staff_in_stores')
|
.from(Tables.staffInStores)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('staff_member_id', staffId);
|
.eq('staff_member_id', staffId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -10,7 +11,7 @@ class StoreRepository {
|
|||||||
/// Crea un nuovo negozio associato alla compagnia dell'utente
|
/// Crea un nuovo negozio associato alla compagnia dell'utente
|
||||||
Future<void> createStore(StoreModel store) async {
|
Future<void> createStore(StoreModel store) async {
|
||||||
try {
|
try {
|
||||||
await _supabase.from('store').insert(store.toMap());
|
await _supabase.from(Tables.stores).insert(store.toMap());
|
||||||
} on PostgrestException catch (e) {
|
} on PostgrestException catch (e) {
|
||||||
// Intercettiamo errori specifici del database
|
// Intercettiamo errori specifici del database
|
||||||
throw e.message;
|
throw e.message;
|
||||||
@@ -22,7 +23,7 @@ class StoreRepository {
|
|||||||
Future<StoreModel> saveStore(StoreModel store) async {
|
Future<StoreModel> saveStore(StoreModel store) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('store')
|
.from(Tables.stores)
|
||||||
.upsert(store.toMap())
|
.upsert(store.toMap())
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -41,7 +42,7 @@ class StoreRepository {
|
|||||||
try {
|
try {
|
||||||
// 1. Eliminiamo tutte le associazioni correnti per questo negozio
|
// 1. Eliminiamo tutte le associazioni correnti per questo negozio
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('providers_in_stores')
|
.from(Tables.providersInStores)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('store_id', storeId);
|
.eq('store_id', storeId);
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ class StoreRepository {
|
|||||||
.map((p) => {'store_id': storeId, 'provider_id': p.id})
|
.map((p) => {'store_id': storeId, 'provider_id': p.id})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
await _supabase.from('providers_in_stores').insert(inserts);
|
await _supabase.from(Tables.providersInStores).insert(inserts);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Errore durante la sincronizzazione provider: $e';
|
throw 'Errore durante la sincronizzazione provider: $e';
|
||||||
@@ -64,7 +65,10 @@ class StoreRepository {
|
|||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// 1. Eliminiamo tutte le associazioni correnti per questo negozio
|
// 1. Eliminiamo tutte le associazioni correnti per questo negozio
|
||||||
await _supabase.from('staff_in_stores').delete().eq('store_id', storeId);
|
await _supabase
|
||||||
|
.from(Tables.staffInStores)
|
||||||
|
.delete()
|
||||||
|
.eq('store_id', storeId);
|
||||||
|
|
||||||
// 2. Se ci sono nuovi dipendenti da associare, li inseriamo
|
// 2. Se ci sono nuovi dipendenti da associare, li inseriamo
|
||||||
if (staffMembers.isNotEmpty) {
|
if (staffMembers.isNotEmpty) {
|
||||||
@@ -72,7 +76,7 @@ class StoreRepository {
|
|||||||
.map((s) => {'store_id': storeId, 'staff_id': s.id})
|
.map((s) => {'store_id': storeId, 'staff_id': s.id})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
await _supabase.from('staff_in_stores').insert(inserts);
|
await _supabase.from(Tables.staffInStores).insert(inserts);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Errore durante la sincronizzazione staff: $e';
|
throw 'Errore durante la sincronizzazione staff: $e';
|
||||||
@@ -83,16 +87,16 @@ class StoreRepository {
|
|||||||
Future<List<StoreModel>> fetchAllCompanyStores(String companyId) async {
|
Future<List<StoreModel>> fetchAllCompanyStores(String companyId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from('store')
|
.from(Tables.stores)
|
||||||
.select('''
|
.select('''
|
||||||
*,
|
*,
|
||||||
associated_providers:providers_in_stores (
|
associated_providers:${Tables.providersInStores} (
|
||||||
provider (
|
${Tables.providers} (
|
||||||
*
|
*
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
associated_staff:staff_in_stores (
|
associated_staff:${Tables.staffInStores} (
|
||||||
staff_member (
|
${Tables.staffMembers} (
|
||||||
*
|
*
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -111,7 +115,7 @@ class StoreRepository {
|
|||||||
required String providerId,
|
required String providerId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _supabase.from('providers_in_stores').insert({
|
await _supabase.from(Tables.providersInStores).insert({
|
||||||
'store_id': storeId,
|
'store_id': storeId,
|
||||||
'provider_id': providerId,
|
'provider_id': providerId,
|
||||||
});
|
});
|
||||||
@@ -126,7 +130,7 @@ class StoreRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from('providers_in_stores')
|
.from(Tables.providersInStores)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('store_id', storeId)
|
.eq('store_id', storeId)
|
||||||
.eq('provider_id', providerId);
|
.eq('provider_id', providerId);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_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/models/provider_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
@@ -177,7 +177,7 @@ class _StoreCardState extends State<StoreCard> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
|
||||||
builder: (context) => BlocBuilder<ProvidersCubit, ProvidersState>(
|
builder: (context) => BlocBuilder<ProviderListCubit, ProviderListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|||||||
80
lib/features/notes/blocs/notes_bloc.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.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 'notes_event.dart';
|
||||||
|
part 'notes_state.dart';
|
||||||
|
|
||||||
|
class NotesBloc extends Bloc<NotesEvent, NotesState> {
|
||||||
|
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
|
||||||
|
final String _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
final String _currentStaffId = GetIt.I
|
||||||
|
.get<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStaffMember!
|
||||||
|
.id!;
|
||||||
|
|
||||||
|
NotesBloc() : super(const NotesState()) {
|
||||||
|
on<SubscribeToNotesRequested>(_onSubscribeToNotesRequested);
|
||||||
|
on<NoteSavedRequested>(_onNoteSavedRequested);
|
||||||
|
on<NoteDeletedRequested>(_onNoteDeletedRequested);
|
||||||
|
|
||||||
|
// Facciamo partire l'ascolto in tempo reale al boot del BLoC
|
||||||
|
add(SubscribeToNotesRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSubscribeToNotesRequested(
|
||||||
|
SubscribeToNotesRequested event,
|
||||||
|
Emitter<NotesState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: NotesStatus.loading));
|
||||||
|
|
||||||
|
// Usiamo l'emit.forEach sullo stream pulito del repository
|
||||||
|
await emit.forEach<List<NoteModel>>(
|
||||||
|
_repository.notesStream(
|
||||||
|
companyId: _companyId,
|
||||||
|
currentStaffId: _currentStaffId,
|
||||||
|
),
|
||||||
|
onData: (notesList) {
|
||||||
|
return state.copyWith(status: NotesStatus.success, notes: notesList);
|
||||||
|
},
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
return state.copyWith(
|
||||||
|
status: NotesStatus.failure,
|
||||||
|
errorMessage: 'Errore nello stream realtime: $error',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNoteSavedRequested(
|
||||||
|
NoteSavedRequested event,
|
||||||
|
Emitter<NotesState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await _repository.saveNote(event.note);
|
||||||
|
// Non serve fare l'emit! Ci pensa lo stream a far rimbalzare i dati aggiornati
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNoteDeletedRequested(
|
||||||
|
NoteDeletedRequested event,
|
||||||
|
Emitter<NotesState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await _repository.deleteNote(event.noteId);
|
||||||
|
// Anche qui, lo stream rileva la cancellazione in automatico
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/features/notes/blocs/notes_event.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
part of 'notes_bloc.dart';
|
||||||
|
|
||||||
|
sealed class NotesEvent {}
|
||||||
|
|
||||||
|
/// Fa partire lo stream e gestisce sia il caricamento iniziale che il realtime
|
||||||
|
class SubscribeToNotesRequested extends NotesEvent {}
|
||||||
|
|
||||||
|
class NoteDeletedRequested extends NotesEvent {
|
||||||
|
final String noteId;
|
||||||
|
NoteDeletedRequested(this.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Salva o aggiorna una nota
|
||||||
|
class NoteSavedRequested extends NotesEvent {
|
||||||
|
final NoteModel note;
|
||||||
|
NoteSavedRequested(this.note);
|
||||||
|
}
|
||||||
39
lib/features/notes/blocs/notes_state.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
part of 'notes_bloc.dart';
|
||||||
|
|
||||||
|
enum NotesStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class NotesState {
|
||||||
|
final NotesStatus status;
|
||||||
|
final List<NoteModel> notes;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const NotesState({
|
||||||
|
this.status = NotesStatus.initial,
|
||||||
|
this.notes = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
NotesState copyWith({
|
||||||
|
NotesStatus? status,
|
||||||
|
List<NoteModel>? notes,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return NotesState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is NotesState &&
|
||||||
|
other.status == status &&
|
||||||
|
listEquals(other.notes, notes) &&
|
||||||
|
other.errorMessage == errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => status.hashCode ^ notes.hashCode ^ errorMessage.hashCode;
|
||||||
|
}
|
||||||
150
lib/features/notes/data/notes_repository.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
class NotesRepository {
|
||||||
|
final _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
|
||||||
|
String get _companyId => GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
String get _currentStaffId =>
|
||||||
|
GetIt.I.get<SessionCubit>().state.currentStaffMember!.id!;
|
||||||
|
|
||||||
|
/// Recupera tutte le note visibili dall'utente corrente:
|
||||||
|
/// 1. Note create da lui
|
||||||
|
/// 2. Note condivise a tutti (is_shared_all = true)
|
||||||
|
/// 3. Note in cui l'utente è esplicitamente un collaboratore
|
||||||
|
Future<List<NoteModel>> getNotes() async {
|
||||||
|
try {
|
||||||
|
// Usiamo la sintassi avanzata di Supabase per fare il filtro OR sulle relazioni
|
||||||
|
// Inoltre tiriamo giù note_collaborators e l'anagrafica dei membri dello staff associati
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.notes)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
${Tables.noteCollaborators}!inner (
|
||||||
|
staff_id,
|
||||||
|
${Tables.staffMembers} (*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
// Filtro multi-tenant di sicurezza (già ridondante con RLS ma ottimo per performance)
|
||||||
|
.eq('company_id', _companyId)
|
||||||
|
// Questa è la magia: l'utente vede la nota se è sua, se è pubblica o se è tra i collaboratori
|
||||||
|
.or(
|
||||||
|
'created_by.eq.$_currentStaffId,is_shared_all.eq.true,note_collaborators.staff_id.eq.$_currentStaffId',
|
||||||
|
)
|
||||||
|
// Ordiniamo prima per sticky (pinned) e poi per data di aggiornamento
|
||||||
|
.order('is_pinned', ascending: false)
|
||||||
|
.order('updated_at', ascending: false);
|
||||||
|
|
||||||
|
return (response as List)
|
||||||
|
.map((json) => NoteModel.fromMap(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
// In caso di errore sulla join !inner se non ci sono collaboratori,
|
||||||
|
// facciamo un fallback pulito su una query standard e uniamo i dati.
|
||||||
|
return _getNotesFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback sicuro nel caso la query complessa con !inner si inceppi se la lista collaboratori è vuota
|
||||||
|
Future<List<NoteModel>> _getNotesFallback() async {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.notes)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
${Tables.noteCollaborators} (
|
||||||
|
staff_id,
|
||||||
|
${Tables.staffMembers} (*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.eq('company_id', _companyId)
|
||||||
|
.order('is_pinned', ascending: false)
|
||||||
|
.order('updated_at', ascending: false);
|
||||||
|
|
||||||
|
final allNotes = (response as List)
|
||||||
|
.map((json) => NoteModel.fromMap(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filtriamo lato codice per essere sicuri della visibilità
|
||||||
|
return allNotes.where((note) {
|
||||||
|
return note.createdBy == _currentStaffId ||
|
||||||
|
note.isSharedAll ||
|
||||||
|
note.collaboratorIds.contains(_currentStaffId);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<NoteModel>> notesStream({
|
||||||
|
required String companyId,
|
||||||
|
required String currentStaffId,
|
||||||
|
}) {
|
||||||
|
return _supabase
|
||||||
|
.from(Tables.notes)
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.order('is_pinned', ascending: false)
|
||||||
|
// Nota: puoi ordinare solo per un campo alla volta nello stream nativo di Supabase.
|
||||||
|
// Ordiniamo per is_pinned, l'ordinamento per data lo facciamo al volo in RAM se serve,
|
||||||
|
// oppure ordiniamo per updated_at e il pin lo gestiamo via software.
|
||||||
|
.map((rawNotes) {
|
||||||
|
return rawNotes.map((json) => NoteModel.fromMap(json)).where((note) {
|
||||||
|
// Filtro multi-tenant di sicurezza in memoria
|
||||||
|
return note.createdBy == currentStaffId ||
|
||||||
|
note.isSharedAll ||
|
||||||
|
note.collaboratorIds.contains(currentStaffId);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> saveNote(NoteModel note) async {
|
||||||
|
// 1. Eseguiamo l'upsert sulla tabella principale 'notes'
|
||||||
|
// Supabase gestisce in automatico: se l'id è null inserisce, se c'è fa update.
|
||||||
|
// Usiamo il .select().single() per farci restituire l'id generato (in caso di nuova nota)
|
||||||
|
final noteResponse = await _supabase
|
||||||
|
.from(Tables.notes)
|
||||||
|
.upsert({
|
||||||
|
if (note.id != null) 'id': note.id,
|
||||||
|
'company_id': note.companyId,
|
||||||
|
'created_by': note.createdBy,
|
||||||
|
'title': note.title,
|
||||||
|
'content': note.content,
|
||||||
|
'color': note.color,
|
||||||
|
'is_pinned': note.isPinned,
|
||||||
|
'is_shared_all': note.isSharedAll,
|
||||||
|
'updated_at': DateTime.now().toIso8601String(),
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
final noteId = noteResponse['id'] as String;
|
||||||
|
|
||||||
|
// Se la nota è condivisa con tutti, spazziamo via eventuali collaboratori singoli e usciamo
|
||||||
|
if (note.isSharedAll) {
|
||||||
|
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
|
||||||
|
return noteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. LA STRATEGIA DEL PIAZZA PULITA SUI COLLABORATORI
|
||||||
|
// Prima di tutto eliminiamo TUTTI i collaboratori attuali per questa specifica nota
|
||||||
|
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
|
||||||
|
|
||||||
|
// 3. RE-INSERIMENTO DELLA LISTA AGGIORNATA
|
||||||
|
// Se ci sono collaboratori da inserire, li prepariamo in blocco (Bulk Insert)
|
||||||
|
if (note.collaboratorIds.isNotEmpty) {
|
||||||
|
final collaboratorsToInsert = note.collaboratorIds
|
||||||
|
.map((staffId) => {'note_id': noteId, 'staff_id': staffId})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _supabase.from('note_collaborators').insert(collaboratorsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restituiamo l'id alla UI (fondamentale per la nostra logica Ninja di creazione)
|
||||||
|
return noteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elimina una nota (i collaboratori si cancellano in cascata grazie al vincolo del DB)
|
||||||
|
Future<void> deleteNote(String noteId) async {
|
||||||
|
await _supabase.from(Tables.notes).delete().eq('id', noteId);
|
||||||
|
}
|
||||||
|
}
|
||||||