Compare commits
3 Commits
c610d68b9c
...
migration
| Author | SHA1 | Date | |
|---|---|---|---|
| 71efc18c05 | |||
| 5214ea9745 | |||
| 1115d2cb87 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,7 +6,7 @@
|
|||||||
*.env
|
*.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
.build/*
|
.build/
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
|
|||||||
4
.wrangler/cache/pages.json
vendored
4
.wrangler/cache/pages.json
vendored
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"account_id": "6badf20faeef39fa5c99283f46f07508",
|
|
||||||
"project_name": "flux"
|
|
||||||
}
|
|
||||||
6
.wrangler/cache/wrangler-account.json
vendored
6
.wrangler/cache/wrangler-account.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"account": {
|
|
||||||
"id": "6badf20faeef39fa5c99283f46f07508",
|
|
||||||
"name": "Marco@catelli.it's Account"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
|||||||
86
android/app/google-services.json
Normal file
86
android/app/google-services.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "872447580790",
|
||||||
|
"project_id": "assistenza-catelli",
|
||||||
|
"storage_bucket": "assistenza-catelli.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:872447580790:android:193235afcc2920ce5d9d57",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.catelli.scans2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:872447580790:android:9c6172d77b1d2cae5d9d57",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.catellisrl.assistenza"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:872447580790:android:425d21710d7682005d9d57",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.catellisrl.catelli_energy_comparator"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:872447580790:android:a1d8d57960451f935d9d57",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.catellisrl.flux"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:label="flux_deep_link">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="flux" />
|
<data android:scheme="fluxapp" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services") version("4.3.15") apply false
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
assets/schedeRiparazione-1778021345.json
Normal file
1
assets/schedeRiparazione-1778021345.json
Normal file
File diff suppressed because one or more lines are too long
1
firebase.json
Normal file
1
firebase.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"assistenza-catelli","appId":"1:872447580790:android:a1d8d57960451f935d9d57","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"assistenza-catelli","configurations":{"android":"1:872447580790:android:a1d8d57960451f935d9d57","ios":"1:872447580790:ios:a87d56c718aa61e05d9d57","macos":"1:872447580790:ios:a87d56c718aa61e05d9d57","web":"1:872447580790:web:10745e7f9afb447d5d9d57","windows":"1:872447580790:web:3b1623eda6abdac75d9d57"}}}}}}
|
||||||
@@ -143,10 +143,6 @@ class SessionCubit extends Cubit<SessionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateCurrentCompany(CompanyModel newCompany) {
|
|
||||||
emit(state.copyWith(company: newCompany));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD ---
|
// --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD ---
|
||||||
Future<void> changeStore(StoreModel newStore) async {
|
Future<void> changeStore(StoreModel newStore) async {
|
||||||
if (newStore.id != null) {
|
if (newStore.id != null) {
|
||||||
@@ -164,8 +160,4 @@ 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,7 +25,6 @@ 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,
|
||||||
@@ -35,7 +34,6 @@ 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
|
||||||
@@ -47,7 +45,6 @@ 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,
|
||||||
@@ -57,7 +54,6 @@ 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +66,6 @@ class SessionState extends Equatable {
|
|||||||
currentStaffMember,
|
currentStaffMember,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
isMobileDevice,
|
isMobileDevice,
|
||||||
isSingleUserMode,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper rapidi per la UI
|
// Helper rapidi per la UI
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ 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/core_repository.dart';
|
import 'package:flux/core/data/core_repository.dart';
|
||||||
import 'package:flux/core/layout/app_shell.dart';
|
import 'package:flux/core/layout/app_shell.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
|
|
||||||
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
|
|
||||||
import 'package:flux/core/widgets/set_password_screen.dart';
|
import 'package:flux/core/widgets/set_password_screen.dart';
|
||||||
import 'package:flux/core/widgets/image_upload/ui/upload_success_screen.dart';
|
|
||||||
import 'package:flux/features/auth/ui/auth_screen.dart';
|
import 'package:flux/features/auth/ui/auth_screen.dart';
|
||||||
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
|
import 'package:flux/features/customers/blocs/customer_files_bloc.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/customers_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
||||||
|
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart';
|
||||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
import 'package:flux/features/customers/ui/customers_content.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';
|
||||||
@@ -23,27 +20,23 @@ 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_cubit.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
|
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/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/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/operations/blocs/operation_files_bloc.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
|
||||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/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_mobile_upload_screen.dart';
|
||||||
import 'package:flux/features/settings/settings_screen.dart';
|
import 'package:flux/features/operations/ui/operations_screen.dart';
|
||||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
|
||||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
|
||||||
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
|
||||||
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
|
|
||||||
import 'package: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';
|
||||||
|
|
||||||
|
// Nota: Dovrai creare questi placeholder o file per non avere errori di compilazione
|
||||||
|
// import 'package:flux/features/master_data/master_data_hub_screen.dart';
|
||||||
|
// import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||||
|
// import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
static GoRouter createRouter(SessionCubit sessionCubit) {
|
static GoRouter createRouter(SessionCubit sessionCubit) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -55,35 +48,18 @@ class AppRouter {
|
|||||||
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
|
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
|
||||||
final isGoingToSetPassword = state.matchedLocation == '/set-password';
|
final isGoingToSetPassword = state.matchedLocation == '/set-password';
|
||||||
|
|
||||||
// 1. LA PASSATOIA VIP (DEVE ESSERE IN CIMA)
|
|
||||||
// Usiamo state.uri.path perché state.matchedLocation a volte fa i capricci coi deep link iniziali
|
|
||||||
final isPublicRoute = state.uri.path.startsWith('/upload');
|
|
||||||
|
|
||||||
if (isPublicRoute) {
|
|
||||||
// Ritorna null esplicitamente per dire al router "Rimani qui e non fare altri controlli"
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. CONTROLLO INIZIALE
|
|
||||||
// Se la sessione sta ancora caricando la primissima volta (es. splash screen logico)
|
|
||||||
if (sessionState.status == SessionStatus.initial) return null;
|
if (sessionState.status == SessionStatus.initial) return null;
|
||||||
|
|
||||||
// 3. UTENTE NON LOGGATO (Ma ci arriva solo se non è su /upload)
|
|
||||||
if (sessionState.status == SessionStatus.unauthenticated) {
|
if (sessionState.status == SessionStatus.unauthenticated) {
|
||||||
// Se sta già andando alle uniche altre pagine pubbliche, lascialo andare
|
|
||||||
if (isGoingToLogin || isGoingToSetPassword) return null;
|
if (isGoingToLogin || isGoingToSetPassword) return null;
|
||||||
// Altrimenti bloccalo e mandalo al login
|
|
||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. UTENTE LOGGATO MA DEVE COMPLETARE L'ONBOARDING
|
|
||||||
if (sessionState.status == SessionStatus.onboardingRequired) {
|
if (sessionState.status == SessionStatus.onboardingRequired) {
|
||||||
return isGoingToOnboarding ? null : '/onboarding';
|
return isGoingToOnboarding ? null : '/onboarding';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. UTENTE PERFETTAMENTE LOGGATO E OPERATIVO
|
|
||||||
if (sessionState.status == SessionStatus.authenticated) {
|
if (sessionState.status == SessionStatus.authenticated) {
|
||||||
// Se per sbaglio cerca di tornare al login o all'onboarding, ributtalo in dashboard
|
|
||||||
if (isGoingToLogin || isGoingToOnboarding) return '/';
|
if (isGoingToLogin || isGoingToOnboarding) return '/';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -94,17 +70,14 @@ class AppRouter {
|
|||||||
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
|
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: Routes.login,
|
|
||||||
builder: (context, state) => const AuthScreen(),
|
builder: (context, state) => const AuthScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/set-password',
|
path: '/set-password',
|
||||||
name: Routes.setPassword,
|
|
||||||
builder: (context, state) => const SetPasswordScreen(),
|
builder: (context, state) => const SetPasswordScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/onboarding',
|
path: '/onboarding',
|
||||||
name: Routes.onboarding,
|
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => BlocProvider(
|
||||||
create: (context) => OnboardingCubit(
|
create: (context) => OnboardingCubit(
|
||||||
GetIt.I.get<SessionCubit>(),
|
GetIt.I.get<SessionCubit>(),
|
||||||
@@ -119,21 +92,15 @@ class AppRouter {
|
|||||||
builder: (context, state, child) => AppShell(child: child),
|
builder: (context, state, child) => AppShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
// 1. DASHBOARD
|
// 1. DASHBOARD
|
||||||
GoRoute(
|
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
||||||
path: '/',
|
|
||||||
name: Routes.home,
|
|
||||||
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,
|
|
||||||
builder: (context, state) => const MasterDataHubScreen(),
|
builder: (context, state) => const MasterDataHubScreen(),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'products', // Diventa /master-data/products
|
path: 'products', // Diventa /master-data/products
|
||||||
name: Routes.products,
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
context.read<ProductsCubit>().refreshCubit();
|
context.read<ProductsCubit>().refreshCubit();
|
||||||
|
|
||||||
@@ -141,26 +108,15 @@ class AppRouter {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'company-settings',
|
path: 'staff', // Diventa /master-data/staff
|
||||||
name: Routes.companySettings,
|
|
||||||
builder: (context, state) => BlocProvider(
|
|
||||||
create: (context) => CompanySettingsCubit(),
|
|
||||||
child: const CompanySettingsScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: 'staff',
|
|
||||||
name: Routes.staff, // Diventa /master-data/staff
|
|
||||||
builder: (context, state) => const StaffScreen(),
|
builder: (context, state) => const StaffScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.stores,
|
path: 'stores', // Diventa /master-data/stores
|
||||||
name: 'stores', // Diventa /master-data/stores
|
|
||||||
builder: (context, state) => const StoresScreen(),
|
builder: (context, state) => const StoresScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'providers',
|
path: 'providers', // Diventa /master-data/providers
|
||||||
name: Routes.providers, // Diventa /master-data/providers
|
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
const ProvidersMasterDataScreen(),
|
const ProvidersMasterDataScreen(),
|
||||||
),
|
),
|
||||||
@@ -170,135 +126,60 @@ class AppRouter {
|
|||||||
// 3. IMPOSTAZIONI
|
// 3. IMPOSTAZIONI
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: Routes.settings,
|
builder: (context, state) => Scaffold(
|
||||||
builder: (context, state) => const SettingsScreen(),
|
appBar: AppBar(title: Text(context.l10n.commonSettings)),
|
||||||
routes: [
|
body: Center(
|
||||||
GoRoute(
|
child: ElevatedButton.icon(
|
||||||
path: 'themeSettings',
|
onPressed: () => context.read<SessionCubit>().signOut(),
|
||||||
name: Routes.themeSettings,
|
icon: const Icon(Icons.logout),
|
||||||
builder: (context, state) => const ThemeSettingsView(),
|
label: const Text("Esci da FLUX"),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/operations',
|
path: '/operations',
|
||||||
name: Routes.operations,
|
builder: (context, state) => const OperationsScreen(),
|
||||||
builder: (context, state) => BlocProvider(
|
|
||||||
create: (context) => OperationListCubit(),
|
|
||||||
child: const OperationListScreen(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/customers',
|
path: '/customers',
|
||||||
name: Routes.customers,
|
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: '/tickets',
|
|
||||||
name: Routes.tickets,
|
|
||||||
builder: (context, state) => BlocProvider(
|
|
||||||
create: (context) => TicketListCubit(),
|
|
||||||
child: const TicketListScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||||
GoRoute(
|
GoRoute(
|
||||||
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
|
path: '/customer/:id',
|
||||||
path: '/tickets/form/:id',
|
|
||||||
name: Routes.ticketForm,
|
|
||||||
builder: (context, state) {
|
|
||||||
// 1. Leggiamo l'ID dall'URL
|
|
||||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
|
||||||
|
|
||||||
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
|
|
||||||
final record =
|
|
||||||
state.extra
|
|
||||||
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
|
|
||||||
|
|
||||||
// 3. LOGICA SOBRIA
|
|
||||||
final String? realTicketId;
|
|
||||||
|
|
||||||
if (pathId == 'new') {
|
|
||||||
realTicketId = null;
|
|
||||||
} else if (record?.ticket?.id != null) {
|
|
||||||
// <-- Parentesi TONDE per la condizione, GRAFFE per il blocco!
|
|
||||||
realTicketId = record!.ticket!.id;
|
|
||||||
} else {
|
|
||||||
realTicketId = pathId;
|
|
||||||
}
|
|
||||||
context.read<CustomersCubit>().loadCustomers();
|
|
||||||
context.read<ProductsCubit>().loadModels();
|
|
||||||
context.read<ProductsCubit>().loadBrands();
|
|
||||||
|
|
||||||
return MultiBlocProvider(
|
|
||||||
providers: [
|
|
||||||
BlocProvider(
|
|
||||||
create: (context) => AttachmentsBloc(
|
|
||||||
parentType: AttachmentParentType.ticket,
|
|
||||||
parentId: realTicketId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BlocProvider(
|
|
||||||
create: (context) => TicketFormCubit(
|
|
||||||
// Passiamo il creatore e l'eventuale ticket esistente presi dal Record!
|
|
||||||
createdBy: record?.createdBy,
|
|
||||||
existingTicket: record?.ticket,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
child: TicketFormScreen(
|
|
||||||
ticketId: realTicketId,
|
|
||||||
existingTicket: record?.ticket,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/upload-success',
|
|
||||||
name: Routes.uploadSuccess,
|
|
||||||
builder: (context, state) => const UploadSuccessScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/customer/form/:id',
|
|
||||||
name: 'customer-form',
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final customer = state.extra as CustomerModel;
|
final customer = state.extra as CustomerModel;
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => AttachmentsBloc(
|
create: (context) => CustomerFilesBloc(customer.id!),
|
||||||
parentType: AttachmentParentType.customer,
|
|
||||||
parentId: customer.id,
|
|
||||||
),
|
|
||||||
child: CustomerDetailScreen(customer: customer),
|
child: CustomerDetailScreen(customer: customer),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/operations/form/:id',
|
path: '/customer/:id/upload',
|
||||||
name: Routes.operationForm,
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
final customerId = state.pathParameters['id']!;
|
||||||
|
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
||||||
final record =
|
return BlocProvider(
|
||||||
state.extra
|
create: (context) => CustomerFilesBloc(customerId),
|
||||||
as ({
|
child: CustomerMobileUploadScreen(
|
||||||
StaffMemberModel? createdBy,
|
customerId: customerId,
|
||||||
OperationModel? operation,
|
customerName: customerName,
|
||||||
})?;
|
),
|
||||||
|
);
|
||||||
final String? realOperationId;
|
},
|
||||||
if (pathId == 'new') {
|
),
|
||||||
realOperationId = null;
|
GoRoute(
|
||||||
} else if (record?.operation?.id != null) {
|
path: '/operation-form',
|
||||||
realOperationId = record!.operation!.id;
|
name: 'operation-form',
|
||||||
} else {
|
builder: (context, state) {
|
||||||
realOperationId = pathId;
|
final existingOperation = state.extra as OperationModel?;
|
||||||
}
|
final operationId = state.uri.queryParameters['operationId'];
|
||||||
final currentStoreId = GetIt.I
|
final currentStoreId = GetIt.I
|
||||||
.get<SessionCubit>()
|
.get<SessionCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -310,57 +191,42 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
context.read<ProductsCubit>().loadModels();
|
context.read<ProductsCubit>().loadModels();
|
||||||
context.read<ProductsCubit>().loadBrands();
|
context.read<ProductsCubit>().loadBrands();
|
||||||
return MultiBlocProvider(
|
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||||
providers: [
|
|
||||||
BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => AttachmentsBloc(
|
create: (context) => OperationFilesBloc(
|
||||||
parentId: realOperationId,
|
operationId: operationId ?? existingOperation?.id,
|
||||||
parentType: AttachmentParentType.operation,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
BlocProvider(
|
|
||||||
create: (context) => OperationFormCubit(
|
|
||||||
createdBy: record?.createdBy,
|
|
||||||
existingOperation: record?.operation,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: OperationFormScreen(
|
child: OperationFormScreen(
|
||||||
operationId: realOperationId,
|
operationId: operationId ?? existingOperation?.id,
|
||||||
existingOperation: record?.operation,
|
existingOperation: existingOperation,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/upload/:type/:id',
|
path: '/operation/:id/upload',
|
||||||
name: Routes.upload,
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final typeString = state.pathParameters['type']!;
|
final operationId = state.pathParameters['id']!;
|
||||||
final id = state.pathParameters['id']!;
|
final operationName =
|
||||||
final companyId = state.uri.queryParameters['companyId']!;
|
state.uri.queryParameters['name'] ?? 'Pratica';
|
||||||
|
final currentStoreId = GetIt.I
|
||||||
// Trasformiamo la stringa dell'URL nel nostro amato Enum!
|
.get<SessionCubit>()
|
||||||
final parentType = AttachmentParentType.values.firstWhere(
|
.state
|
||||||
(e) => e.name == typeString,
|
.currentStore!
|
||||||
orElse: () =>
|
.id!;
|
||||||
AttachmentParentType.ticket, // Fallback di sicurezza
|
context.read<CustomersCubit>().loadCustomers();
|
||||||
|
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||||
|
currentStoreId,
|
||||||
);
|
);
|
||||||
|
context.read<ProductsCubit>().loadModels();
|
||||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
context.read<ProductsCubit>().loadBrands();
|
||||||
return MultiBlocProvider(
|
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||||
providers: [
|
return BlocProvider(
|
||||||
BlocProvider<AttachmentsBloc>(
|
create: (context) => OperationFilesBloc(operationId: operationId),
|
||||||
create: (context) =>
|
child: OperationMobileUploadScreen(
|
||||||
AttachmentsBloc(parentId: id, parentType: parentType),
|
operationId: operationId,
|
||||||
),
|
operationName: operationName,
|
||||||
BlocProvider(create: (context) => ImageUploadCubit()),
|
|
||||||
],
|
|
||||||
|
|
||||||
child: ImageUploadScreen(
|
|
||||||
title: 'Caricamento Rapido',
|
|
||||||
companyId: companyId,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
class Routes {
|
|
||||||
static const String login = 'login';
|
|
||||||
static const String setPassword = 'set-password';
|
|
||||||
static const String onboarding = 'onboarding';
|
|
||||||
static const String home = 'home';
|
|
||||||
static const String masterData = 'master-data';
|
|
||||||
static const String products = 'products';
|
|
||||||
static const String companySettings = 'company-settings';
|
|
||||||
static const String staff = 'staff';
|
|
||||||
static const String stores = 'stores';
|
|
||||||
static const String providers = 'providers';
|
|
||||||
static const String settings = 'settings';
|
|
||||||
static const String themeSettings = 'themeSettings';
|
|
||||||
static const String operations = 'operations';
|
|
||||||
static const String customers = 'customers';
|
|
||||||
static const String tickets = 'tickets';
|
|
||||||
static const String ticketForm = 'ticket-form';
|
|
||||||
static const String operationForm = 'operation-form';
|
|
||||||
static const String uploadSuccess = 'upload-success';
|
|
||||||
static const String customerForm = 'customer-form';
|
|
||||||
static const String upload = 'upload';
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
|
|
||||||
part 'image_upload_state.dart';
|
|
||||||
|
|
||||||
class ImageUploadCubit extends Cubit<ImageUploadState> {
|
|
||||||
ImageUploadCubit() : super(const ImageUploadState());
|
|
||||||
|
|
||||||
void setStatus(ImageUploadStatus status) {
|
|
||||||
emit(state.copyWith(status: status));
|
|
||||||
}
|
|
||||||
|
|
||||||
void setError(String? message) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: ImageUploadStatus.failure, errorMessage: message),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void addFiles(List<PlatformFile> files) {
|
|
||||||
List<PlatformFile> newFiles = List.from(state.stagedFiles);
|
|
||||||
newFiles.addAll(files);
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeFile(PlatformFile file) {
|
|
||||||
List<PlatformFile> newFiles = List.from(state.stagedFiles);
|
|
||||||
newFiles.remove(file);
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addPhoto(XFile photo) async {
|
|
||||||
final List<PlatformFile> files = List.from(state.stagedFiles);
|
|
||||||
files.add(PlatformFile(name: photo.name, size: 0));
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: ImageUploadStatus.addingPicture,
|
|
||||||
stagedFiles: files,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final List<PlatformFile> newFiles = List.from(files);
|
|
||||||
newFiles.removeLast();
|
|
||||||
final PlatformFile loadedFile = PlatformFile(
|
|
||||||
name: photo.name,
|
|
||||||
size: await photo.length(),
|
|
||||||
bytes: await photo.readAsBytes(),
|
|
||||||
path: photo.path,
|
|
||||||
);
|
|
||||||
newFiles.add(loadedFile);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
part of 'image_upload_cubit.dart';
|
|
||||||
|
|
||||||
enum ImageUploadStatus { initial, addingPicture, uploading, success, failure }
|
|
||||||
|
|
||||||
class ImageUploadState extends Equatable {
|
|
||||||
final ImageUploadStatus status;
|
|
||||||
final String? errorMessage;
|
|
||||||
final List<PlatformFile> stagedFiles;
|
|
||||||
|
|
||||||
const ImageUploadState({
|
|
||||||
this.status = ImageUploadStatus.initial,
|
|
||||||
this.errorMessage,
|
|
||||||
this.stagedFiles = const [],
|
|
||||||
});
|
|
||||||
ImageUploadState copyWith({
|
|
||||||
ImageUploadStatus? status,
|
|
||||||
String? errorMessage,
|
|
||||||
List<PlatformFile>? stagedFiles,
|
|
||||||
}) {
|
|
||||||
return ImageUploadState(
|
|
||||||
status: status ?? this.status,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
stagedFiles: stagedFiles ?? this.stagedFiles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [status, errorMessage, stagedFiles];
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
|
||||||
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
|
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
|
|
||||||
class ImageUploadScreen extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String companyId;
|
|
||||||
|
|
||||||
const ImageUploadScreen({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
required this.companyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool _isImage(String path) {
|
|
||||||
return ['jpg', 'jpeg', 'png', 'webp'].contains(path.fileExtension());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocBuilder<ImageUploadCubit, ImageUploadState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
|
||||||
listener: (context, attachmentState) {
|
|
||||||
if (attachmentState.status == AttachmentsStatus.success &&
|
|
||||||
state.status == ImageUploadStatus.uploading) {
|
|
||||||
if (Navigator.of(context).canPop()) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text("File caricati con successo! ✅"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
} else {
|
|
||||||
context.go('/upload-success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (attachmentState.status == AttachmentsStatus.failure) {
|
|
||||||
context.read<ImageUploadCubit>().setError(attachmentState.error);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Errore: ${state.errorMessage}")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text('Upload: $title'),
|
|
||||||
automaticallyImplyLeading:
|
|
||||||
state.status != ImageUploadStatus.uploading,
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed:
|
|
||||||
state.status == ImageUploadStatus.uploading
|
|
||||||
? null
|
|
||||||
: () => _handleCamera(context),
|
|
||||||
icon: const Icon(Icons.camera_alt),
|
|
||||||
label: const Text('SCATTA'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed:
|
|
||||||
state.status == ImageUploadStatus.uploading
|
|
||||||
? null
|
|
||||||
: () => _handleFilePicker(context),
|
|
||||||
icon: const Icon(Icons.folder),
|
|
||||||
label: const Text("GALLERIA"),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
|
|
||||||
Expanded(
|
|
||||||
child: state.stagedFiles.isEmpty
|
|
||||||
? const Center(
|
|
||||||
child: Text(
|
|
||||||
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: GridView.builder(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
gridDelegate:
|
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount:
|
|
||||||
3, // 3 colonne stile galleria
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
),
|
|
||||||
itemCount: state.stagedFiles.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final file = state.stagedFiles[index];
|
|
||||||
final isImg = _isImage(file.name);
|
|
||||||
if (file.bytes == null) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
// L'ANTEPRIMA
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: isImg
|
|
||||||
? (file.bytes != null
|
|
||||||
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
|
|
||||||
? Image.memory(
|
|
||||||
file.bytes!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
// Altrimenti andiamo di file fisico
|
|
||||||
: const Center(
|
|
||||||
child:
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
: const Column(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.picture_as_pdf,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 36,
|
|
||||||
),
|
|
||||||
SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
"PDF",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight:
|
|
||||||
FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// IL PULSANTE CESTINO (In alto a destra)
|
|
||||||
Positioned(
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => context
|
|
||||||
.read<ImageUploadCubit>()
|
|
||||||
.removeFile(file),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.red,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.close,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// --- SEZIONE INVIA E CHIUDI ---
|
|
||||||
SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 56,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
// Il pulsante si accende SOLO se ci sono file nel carrello
|
|
||||||
onPressed:
|
|
||||||
state.stagedFiles.isEmpty ||
|
|
||||||
state.status == ImageUploadStatus.uploading
|
|
||||||
? null
|
|
||||||
: () => _submitAllFiles(context),
|
|
||||||
icon: const Icon(Icons.cloud_upload),
|
|
||||||
label: Text(
|
|
||||||
"INVIA ${state.stagedFiles.length} FILE E CHIUDI",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary,
|
|
||||||
foregroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
|
||||||
Future<void> _handleCamera(BuildContext context) async {
|
|
||||||
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
|
|
||||||
|
|
||||||
final picker = ImagePicker();
|
|
||||||
final photo = await picker.pickImage(source: ImageSource.camera);
|
|
||||||
|
|
||||||
if (photo != null) {
|
|
||||||
imageUploadCubit.addPhoto(photo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleFilePicker(BuildContext context) async {
|
|
||||||
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
|
|
||||||
final result = await FilePicker.pickFiles(
|
|
||||||
allowMultiple: true,
|
|
||||||
withData: true,
|
|
||||||
);
|
|
||||||
if (result != null) {
|
|
||||||
imageUploadCubit.addFiles(result.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LOGICA DI INVIO AL BLoC ---
|
|
||||||
void _submitAllFiles(BuildContext context) {
|
|
||||||
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
|
|
||||||
|
|
||||||
imageUploadCubit.setStatus(ImageUploadStatus.uploading);
|
|
||||||
|
|
||||||
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
|
|
||||||
context.read<AttachmentsBloc>().add(
|
|
||||||
UploadAttachmentsEvent(
|
|
||||||
pickedFiles: imageUploadCubit.state.stagedFiles,
|
|
||||||
companyId: companyId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class UploadSuccessScreen extends StatelessWidget {
|
|
||||||
const UploadSuccessScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.green.shade50,
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.green.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 20,
|
|
||||||
spreadRadius: 5,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.check, size: 80, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
const Text(
|
|
||||||
"Upload Completato!",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
"I file sono stati caricati con successo sulla pratica.\nPuoi chiudere questa pagina o finestra del browser.",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 16, color: Colors.black54),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
class QrUploadDialog extends StatelessWidget {
|
class QrUploadDialog extends StatelessWidget {
|
||||||
@@ -19,13 +17,7 @@ class QrUploadDialog extends StatelessWidget {
|
|||||||
// Usiamo i colori del tema per renderlo coerente col tuo design
|
// Usiamo i colori del tema per renderlo coerente col tuo design
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
return AlertDialog(
|
||||||
listener: (context, state) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
listenWhen: (previous, current) =>
|
|
||||||
previous.allFiles.length < current.allFiles.length,
|
|
||||||
child: AlertDialog(
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
backgroundColor: theme.colorScheme.surface,
|
backgroundColor: theme.colorScheme.surface,
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -56,6 +48,7 @@ class QrUploadDialog extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
// IL CUORE DELLA MAGIA
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -96,7 +89,6 @@ class QrUploadDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
actionsAlignment: MainAxisAlignment.center,
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
|
||||||
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
|
|
||||||
|
|
||||||
class SharedModelSection extends StatelessWidget {
|
|
||||||
final String? modelId;
|
|
||||||
final String? modelName;
|
|
||||||
final String label;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? borderColor;
|
|
||||||
|
|
||||||
// Usiamo una callback che passa direttamente ID e Nome
|
|
||||||
// così non dobbiamo preoccuparci di importare la classe esatta del modello ovunque
|
|
||||||
final void Function(String id, String name) onModelSelected;
|
|
||||||
|
|
||||||
const SharedModelSection({
|
|
||||||
super.key,
|
|
||||||
required this.modelId,
|
|
||||||
required this.modelName,
|
|
||||||
required this.onModelSelected,
|
|
||||||
this.label = 'Seleziona Modello',
|
|
||||||
this.backgroundColor,
|
|
||||||
this.borderColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final hasModel = modelId != null && modelId!.isNotEmpty;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
tileColor: backgroundColor,
|
|
||||||
title: Text(label),
|
|
||||||
subtitle: Text(
|
|
||||||
hasModel ? modelName! : 'Nessun modello selezionato',
|
|
||||||
style: TextStyle(
|
|
||||||
color: hasModel ? null : Colors.grey,
|
|
||||||
fontWeight: hasModel ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: const Icon(Icons.arrow_drop_down),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
side: BorderSide(color: borderColor ?? theme.dividerColor),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
onTap: () => _showModelModal(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showModelModal(BuildContext context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (modalContext) {
|
|
||||||
return DraggableScrollableSheet(
|
|
||||||
initialChildSize: 0.6,
|
|
||||||
minChildSize: 0.4,
|
|
||||||
maxChildSize: 0.9,
|
|
||||||
expand: false,
|
|
||||||
builder: (_, scrollController) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Seleziona Modello',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Navigator.pop(modalContext),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: TextField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Cerca modello (es. iPhone 15...)',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: (query) =>
|
|
||||||
context.read<ProductsCubit>().searchModels(query),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(48),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Aggiungi Modello al Volo'),
|
|
||||||
onPressed: () async {
|
|
||||||
// Leggiamo i brand dal Cubit per passarli alla dialog
|
|
||||||
final existingBrands = context
|
|
||||||
.read<ProductsCubit>()
|
|
||||||
.state
|
|
||||||
.brands;
|
|
||||||
|
|
||||||
final newModel = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return BlocProvider.value(
|
|
||||||
value: context.read<ProductsCubit>(),
|
|
||||||
child: QuickProductDialog(
|
|
||||||
existingBrands: existingBrands,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newModel != null) {
|
|
||||||
// CHIAMIAMO LA CALLBACK!
|
|
||||||
onModelSelected(newModel.id, newModel.nameWithBrand);
|
|
||||||
if (context.mounted) Navigator.pop(modalContext);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
Expanded(
|
|
||||||
child: BlocBuilder<ProductsCubit, ProductState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return ListView.builder(
|
|
||||||
controller: scrollController,
|
|
||||||
itemCount: state.models.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final deviceModel = state.models[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.devices),
|
|
||||||
title: Text(
|
|
||||||
deviceModel.nameWithBrand,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
// CHIAMIAMO LA CALLBACK!
|
|
||||||
onModelSelected(
|
|
||||||
deviceModel.id!,
|
|
||||||
deviceModel.nameWithBrand,
|
|
||||||
);
|
|
||||||
Navigator.pop(modalContext);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
|
|
||||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
|
|
||||||
class SharedFilesSection extends StatelessWidget {
|
|
||||||
final String titleNameForUpload;
|
|
||||||
// LA NOSTRA CALLBACK MAGICA
|
|
||||||
final Future<String?> Function()? onGenerateIdForQr;
|
|
||||||
|
|
||||||
const SharedFilesSection({
|
|
||||||
super.key,
|
|
||||||
required this.titleNameForUpload,
|
|
||||||
this.onGenerateIdForQr,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Allegati e Foto',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
|
|
||||||
BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
// --- IL TASTO QR CODE (Ora sempre attivo!) ---
|
|
||||||
Tooltip(
|
|
||||||
message: 'Carica foto con lo smartphone',
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
|
||||||
color: theme.colorScheme.primary, // Sempre colorato!
|
|
||||||
onPressed: () async {
|
|
||||||
String? targetId = state.parentId;
|
|
||||||
|
|
||||||
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
|
|
||||||
if (targetId == null) {
|
|
||||||
if (onGenerateIdForQr != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Salvataggio rapido scheda in corso... ⏳',
|
|
||||||
),
|
|
||||||
duration: Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
|
|
||||||
targetId = await onGenerateIdForQr!();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se fallisce (es. validazione form non passata), ci fermiamo
|
|
||||||
if (targetId == null) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GENERAZIONE DEL DEEP LINK AGNOSTICO
|
|
||||||
final companyId = GetIt.I
|
|
||||||
.get<SessionCubit>()
|
|
||||||
.state
|
|
||||||
.company!
|
|
||||||
.id!;
|
|
||||||
final deepLink =
|
|
||||||
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => QrUploadDialog(
|
|
||||||
deepLinkUrl: deepLink,
|
|
||||||
title: 'Carica File: $titleNameForUpload',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
|
|
||||||
// --- IL TASTO AGGIUNGI CLASSICO (da PC) ---
|
|
||||||
TextButton.icon(
|
|
||||||
icon: const Icon(Icons.add_a_photo),
|
|
||||||
label: const Text('Aggiungi'),
|
|
||||||
onPressed: () {
|
|
||||||
final bloc = context.read<AttachmentsBloc>();
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => BlocProvider.value(
|
|
||||||
value: bloc,
|
|
||||||
child: ImageUploadScreen(
|
|
||||||
title: titleNameForUpload,
|
|
||||||
companyId: GetIt.I
|
|
||||||
.get<SessionCubit>()
|
|
||||||
.state
|
|
||||||
.company!
|
|
||||||
.id!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// --- LA VETRINA DEI FILE (Identica a prima) ---
|
|
||||||
BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
final files = state.allFiles;
|
|
||||||
|
|
||||||
if (state.status == AttachmentsStatus.loading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.isEmpty) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.dividerColor,
|
|
||||||
style: BorderStyle.solid,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.image_not_supported_outlined,
|
|
||||||
color: Colors.grey,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Nessun file allegato',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: files.map((file) {
|
|
||||||
final isImage = [
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'png',
|
|
||||||
'webp',
|
|
||||||
].contains(file.extension.toLowerCase());
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: theme.dividerColor),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: isImage
|
|
||||||
? const Icon(
|
|
||||||
Icons.image,
|
|
||||||
color: Colors.blue,
|
|
||||||
size: 40,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.picture_as_pdf,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (file.id == null)
|
|
||||||
Positioned(
|
|
||||||
bottom: 4,
|
|
||||||
left: 4,
|
|
||||||
right: 4,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.orange,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Da salvare',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.cancel,
|
|
||||||
color: Colors.redAccent,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
context.read<AttachmentsBloc>().add(
|
|
||||||
DeleteSpecificAttachmentEvent(file),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|
||||||
// import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
// Importa il tuo StaffModel
|
|
||||||
|
|
||||||
/// Funzione helper globale per lanciare la modale ovunque ti trovi con 1 riga di codice
|
|
||||||
Future<dynamic> showStaffSelectorModal(BuildContext context) async {
|
|
||||||
return showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled:
|
|
||||||
true, // Permette alla modale di essere più alta se serve
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => const StaffSelectorModal(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class StaffSelectorModal extends StatelessWidget {
|
|
||||||
const StaffSelectorModal({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min, // Occupa solo lo spazio necessario
|
|
||||||
children: [
|
|
||||||
// --- Maniglietta superiore (UX standard dei BottomSheet) ---
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(bottom: 24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.dividerColor,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// --- Titolo ---
|
|
||||||
const Text(
|
|
||||||
'Chi sei?',
|
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Seleziona il tuo profilo per continuare',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
BlocBuilder<StaffCubit, StaffState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.status == StaffStatus.loading) {
|
|
||||||
return const CircularProgressIndicator();
|
|
||||||
}
|
|
||||||
final staffList = state.storeStaff;
|
|
||||||
return _buildStaffGrid(context, staffList);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// --- Tasto Annulla ---
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(), // Restituisce null
|
|
||||||
child: const Text('Annulla'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStaffGrid(
|
|
||||||
BuildContext context,
|
|
||||||
List<StaffMemberModel> staffList,
|
|
||||||
) {
|
|
||||||
return Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 16,
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
children: staffList.map((staff) {
|
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
onTap: () {
|
|
||||||
// Quando l'utente tappa il suo nome, la modale si chiude
|
|
||||||
// e restituisce il modello (o l'ID) alla schermata precedente!
|
|
||||||
Navigator.of(context).pop(staff);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
width: 100, // Pulsanti larghi e comodi
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 30,
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
||||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
|
||||||
child: Text(
|
|
||||||
staff.name.substring(0, 1).toUpperCase(),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
staff.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<StaffMemberModel?> getStaffMember(BuildContext context) async {
|
|
||||||
final sessionState = context.read<SessionCubit>().state;
|
|
||||||
|
|
||||||
if (sessionState.isSingleUserMode) {
|
|
||||||
// Dispositivo personale: non rompiamo le palle. Usiamo l'utente loggato.
|
|
||||||
return sessionState.currentStaffMember;
|
|
||||||
} else {
|
|
||||||
// Dispositivo Condiviso (Kiosk Mode): Chiediamo chi è!
|
|
||||||
return await showStaffSelectorModal(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
|
||||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
|
|
||||||
part 'attachments_events.dart';
|
|
||||||
part 'attachments_state.dart';
|
|
||||||
|
|
||||||
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|
||||||
final _repository = GetIt.I.get<AttachmentsRepository>();
|
|
||||||
final String? companyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
|
||||||
|
|
||||||
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
|
|
||||||
: super(
|
|
||||||
AttachmentsState(
|
|
||||||
status: AttachmentsStatus.initial,
|
|
||||||
parentId: parentId,
|
|
||||||
parentType: parentType,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
on<ParentEntitySavedEvent>(_onParentEntitySaved);
|
|
||||||
on<LoadAttachmentsEvent>(_onLoadAttachments);
|
|
||||||
on<AddAttachmentsEvent>(_onAddAttachments);
|
|
||||||
on<UploadAttachmentsEvent>(_onUploadAttachments);
|
|
||||||
on<DeleteAttachmentsEvent>(_onDeleteAttachments);
|
|
||||||
on<ToggleAttachmentSelectionEvent>(_onToggleAttachmentSelection);
|
|
||||||
on<LinkAttachmentsToEntityEvent>(_onLinkAttachmentsToEntity);
|
|
||||||
on<RenameAttachmentEvent>(_onRenameAttachment);
|
|
||||||
on<DeleteSpecificAttachmentEvent>(_onDeleteSpecificAttachment);
|
|
||||||
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
|
|
||||||
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
|
|
||||||
|
|
||||||
// Se il BLoC nasce già con un ID, carichiamo i file
|
|
||||||
if (parentId != null && companyId != null) {
|
|
||||||
add(LoadAttachmentsEvent(parentId: parentId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FutureOr<void> _onParentEntitySaved(
|
|
||||||
ParentEntitySavedEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
parentId: event.newParentId,
|
|
||||||
status: AttachmentsStatus.uploading,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (state.localFiles.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
final List<Future<void>> uploadTasks = state.localFiles.map((file) {
|
|
||||||
final fakePlatformFile = PlatformFile(
|
|
||||||
name: '${file.name}.${file.extension}',
|
|
||||||
size: file.fileSize,
|
|
||||||
bytes: file.localBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chiamiamo il metodo generico passando il parentId e il TYPE
|
|
||||||
return _repository.uploadAndRegisterFile(
|
|
||||||
parentId: event.newParentId,
|
|
||||||
parentType: state.parentType,
|
|
||||||
pickedFile: fakePlatformFile,
|
|
||||||
companyId: companyId!,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await Future.wait(uploadTasks);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: AttachmentsStatus.failure,
|
|
||||||
error: "Errore upload post-salvataggio: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(localFiles: [], status: AttachmentsStatus.ready));
|
|
||||||
add(LoadAttachmentsEvent(parentId: event.newParentId));
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onLoadAttachments(
|
|
||||||
LoadAttachmentsEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
final currentId = event.parentId ?? state.parentId;
|
|
||||||
|
|
||||||
if (currentId != null) {
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.loading));
|
|
||||||
|
|
||||||
await emit.forEach(
|
|
||||||
_repository.getFilesStream(
|
|
||||||
currentId,
|
|
||||||
state.parentType,
|
|
||||||
), // Passiamo il tipo!
|
|
||||||
onData: (List<AttachmentModel> data) =>
|
|
||||||
state.copyWith(status: AttachmentsStatus.ready, remoteFiles: data),
|
|
||||||
onError: (error, stackTrace) => state.copyWith(
|
|
||||||
status: AttachmentsStatus.failure,
|
|
||||||
error: error.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAddAttachments(
|
|
||||||
AddAttachmentsEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
final currentId = state.parentId;
|
|
||||||
|
|
||||||
// BIVIO 1: PRATICA NUOVA (Salvataggio locale)
|
|
||||||
if (currentId == null) {
|
|
||||||
final newLocalFiles = event.files.map((file) {
|
|
||||||
// Assegniamo i campi dinamicamente in base al parentType!
|
|
||||||
return AttachmentModel(
|
|
||||||
id: null,
|
|
||||||
companyId: companyId!,
|
|
||||||
operationId: state.parentType == AttachmentParentType.operation
|
|
||||||
? ''
|
|
||||||
: null,
|
|
||||||
ticketId: state.parentType == AttachmentParentType.ticket ? '' : null,
|
|
||||||
customerId: state.parentType == AttachmentParentType.customer
|
|
||||||
? ''
|
|
||||||
: null,
|
|
||||||
name: file.name.fileNameWithoutExtension(),
|
|
||||||
extension: file.name.fileExtension(),
|
|
||||||
storagePath: '',
|
|
||||||
fileSize: file.size,
|
|
||||||
localBytes: file.bytes,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
localFiles: [...state.localFiles, ...newLocalFiles],
|
|
||||||
status: AttachmentsStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// BIVIO 2: PRATICA ESISTENTE (Upload immediato)
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.uploading));
|
|
||||||
try {
|
|
||||||
final List<Future<void>> uploadTasks = event.files.map((file) {
|
|
||||||
return _repository.uploadAndRegisterFile(
|
|
||||||
parentId: currentId,
|
|
||||||
parentType: state.parentType,
|
|
||||||
pickedFile: file,
|
|
||||||
companyId: companyId!,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await Future.wait(uploadTasks);
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.ready));
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onUploadAttachments(
|
|
||||||
UploadAttachmentsEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
|
|
||||||
(event.photos == null || event.photos!.isEmpty)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.parentId == null) return;
|
|
||||||
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.uploading));
|
|
||||||
try {
|
|
||||||
final List<Future<void>> uploadTasks = [];
|
|
||||||
|
|
||||||
// 1. Gestione Documenti normali (PlatformFile)
|
|
||||||
if (event.pickedFiles != null) {
|
|
||||||
for (var file in event.pickedFiles!) {
|
|
||||||
uploadTasks.add(
|
|
||||||
_repository.uploadAndRegisterFile(
|
|
||||||
parentId: state.parentId!,
|
|
||||||
parentType: state.parentType,
|
|
||||||
pickedFile: file,
|
|
||||||
companyId: event.companyId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Gestione Foto Fotocamera (XFile)
|
|
||||||
if (event.photos != null) {
|
|
||||||
for (var photo in event.photos!) {
|
|
||||||
// Leggiamo i byte asincronamente
|
|
||||||
final bytes = await photo.readAsBytes();
|
|
||||||
final fileSize = await photo.length();
|
|
||||||
|
|
||||||
// Lo travestiamo da PlatformFile per passarlo al Repository!
|
|
||||||
final fakePlatformFile = PlatformFile(
|
|
||||||
name: photo.name,
|
|
||||||
size: fileSize,
|
|
||||||
bytes: bytes,
|
|
||||||
path: photo.path,
|
|
||||||
);
|
|
||||||
|
|
||||||
uploadTasks.add(
|
|
||||||
_repository.uploadAndRegisterFile(
|
|
||||||
parentId: state.parentId!,
|
|
||||||
parentType: state.parentType,
|
|
||||||
pickedFile: fakePlatformFile,
|
|
||||||
companyId: event.companyId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Esecuzione parallela di tutti i documenti e foto
|
|
||||||
await Future.wait(uploadTasks);
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.success));
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onDeleteAttachments(
|
|
||||||
DeleteAttachmentsEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.loading));
|
|
||||||
try {
|
|
||||||
await _repository.deleteFiles(
|
|
||||||
files: state.selectedFiles,
|
|
||||||
currentContextType: state.parentType,
|
|
||||||
);
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onToggleAttachmentSelection(
|
|
||||||
ToggleAttachmentSelectionEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) {
|
|
||||||
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
|
|
||||||
if (selectedFiles.contains(event.file)) {
|
|
||||||
selectedFiles.remove(event.file);
|
|
||||||
} else {
|
|
||||||
selectedFiles.add(event.file);
|
|
||||||
}
|
|
||||||
emit(state.copyWith(selectedFiles: selectedFiles));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSelectAllAttachments(
|
|
||||||
SelectAllAttachmentsEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) {
|
|
||||||
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
|
|
||||||
emit(state.copyWith(selectedFiles: state.allFiles));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onClearAttachmentSelection(
|
|
||||||
ClearAttachmentSelectionEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) {
|
|
||||||
// Svuotiamo brutalmente la lista
|
|
||||||
emit(state.copyWith(selectedFiles: []));
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onLinkAttachmentsToEntity(
|
|
||||||
LinkAttachmentsToEntityEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
if (state.selectedFiles.isEmpty) return;
|
|
||||||
|
|
||||||
// BIVIO 1: PRATICA/TICKET NON ANCORA SALVATA (Modalità Locale)
|
|
||||||
if (state.parentId == null) {
|
|
||||||
final updatedLocalFiles = state.localFiles.map((file) {
|
|
||||||
if (state.selectedFiles.contains(file)) {
|
|
||||||
// Assegniamo dinamicamente l'ID in base all'entità scelta
|
|
||||||
switch (event.targetType) {
|
|
||||||
case AttachmentParentType.customer:
|
|
||||||
return file.copyWith(customerId: event.targetId);
|
|
||||||
case AttachmentParentType.ticket:
|
|
||||||
return file.copyWith(ticketId: event.targetId);
|
|
||||||
case AttachmentParentType.operation:
|
|
||||||
return file.copyWith(operationId: event.targetId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
localFiles: updatedLocalFiles,
|
|
||||||
selectedFiles: [], // Svuotiamo la selezione
|
|
||||||
status: AttachmentsStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// BIVIO 2: PRATICA/TICKET ESISTENTE (Modalità Remota su DB)
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.loading));
|
|
||||||
try {
|
|
||||||
final List<Future<void>> linkTasks = [];
|
|
||||||
|
|
||||||
for (var file in state.selectedFiles) {
|
|
||||||
if (file.id != null) {
|
|
||||||
linkTasks.add(
|
|
||||||
_repository.linkFileToEntity(
|
|
||||||
fileId: file.id!,
|
|
||||||
targetType: event.targetType,
|
|
||||||
targetId: event.targetId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Future.wait(linkTasks);
|
|
||||||
|
|
||||||
// Lo stream aggiornerà automaticamente la UI
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: AttachmentsStatus.failure,
|
|
||||||
error: "Errore durante il collegamento: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onRenameAttachment(
|
|
||||||
RenameAttachmentEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) async {
|
|
||||||
// BIVIO 1: File Locale (Bozza)
|
|
||||||
if (event.file.localBytes != null) {
|
|
||||||
final updatedLocalFiles = state.localFiles.map((f) {
|
|
||||||
if (f == event.file) {
|
|
||||||
return f.copyWith(name: event.newName);
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}).toList();
|
|
||||||
emit(state.copyWith(localFiles: updatedLocalFiles));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// BIVIO 2: File Remoto (Salvato su DB)
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.loading));
|
|
||||||
try {
|
|
||||||
await _repository.renameAttachment(event.file.id!, event.newName);
|
|
||||||
emit(state.copyWith(status: AttachmentsStatus.ready));
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: AttachmentsStatus.failure,
|
|
||||||
error: "Errore rinomina: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onDeleteSpecificAttachment(
|
|
||||||
DeleteSpecificAttachmentEvent event,
|
|
||||||
Emitter<AttachmentsState> emit,
|
|
||||||
) {
|
|
||||||
if (event.file.localBytes != null) {
|
|
||||||
final updatedLocalFiles = state.localFiles
|
|
||||||
.where((f) => f != event.file)
|
|
||||||
.toList();
|
|
||||||
emit(state.copyWith(localFiles: updatedLocalFiles));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
part of 'attachments_bloc.dart';
|
|
||||||
|
|
||||||
abstract class AttachmentsEvent extends Equatable {
|
|
||||||
const AttachmentsEvent();
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chiamato quando l'entità "padre" (es. il Ticket) viene salvata per la prima volta
|
|
||||||
class ParentEntitySavedEvent extends AttachmentsEvent {
|
|
||||||
final String newParentId;
|
|
||||||
const ParentEntitySavedEvent(this.newParentId);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [newParentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class LoadAttachmentsEvent extends AttachmentsEvent {
|
|
||||||
final String? parentId;
|
|
||||||
const LoadAttachmentsEvent({this.parentId});
|
|
||||||
}
|
|
||||||
|
|
||||||
class AddAttachmentsEvent extends AttachmentsEvent {
|
|
||||||
final List<PlatformFile> files;
|
|
||||||
const AddAttachmentsEvent(this.files);
|
|
||||||
}
|
|
||||||
|
|
||||||
class UploadAttachmentsEvent extends AttachmentsEvent {
|
|
||||||
final List<PlatformFile>? pickedFiles;
|
|
||||||
final List<XFile>? photos;
|
|
||||||
final String companyId;
|
|
||||||
const UploadAttachmentsEvent({
|
|
||||||
this.pickedFiles,
|
|
||||||
this.photos,
|
|
||||||
required this.companyId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeleteAttachmentsEvent extends AttachmentsEvent {}
|
|
||||||
|
|
||||||
class ToggleAttachmentSelectionEvent extends AttachmentsEvent {
|
|
||||||
final AttachmentModel file;
|
|
||||||
const ToggleAttachmentSelectionEvent(this.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SelectAllAttachmentsEvent extends AttachmentsEvent {}
|
|
||||||
|
|
||||||
class ClearAttachmentSelectionEvent extends AttachmentsEvent {}
|
|
||||||
|
|
||||||
class LinkAttachmentsToEntityEvent extends AttachmentsEvent {
|
|
||||||
final AttachmentParentType targetType;
|
|
||||||
final String targetId;
|
|
||||||
|
|
||||||
const LinkAttachmentsToEntityEvent({
|
|
||||||
required this.targetType,
|
|
||||||
required this.targetId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [targetType, targetId];
|
|
||||||
}
|
|
||||||
|
|
||||||
class RenameAttachmentEvent extends AttachmentsEvent {
|
|
||||||
final AttachmentModel file;
|
|
||||||
final String newName;
|
|
||||||
const RenameAttachmentEvent(this.file, this.newName);
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeleteSpecificAttachmentEvent extends AttachmentsEvent {
|
|
||||||
final AttachmentModel file;
|
|
||||||
const DeleteSpecificAttachmentEvent(this.file);
|
|
||||||
}
|
|
||||||
@@ -1,199 +1,23 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.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';
|
|
||||||
|
|
||||||
class AttachmentsRepository {
|
class AttachmentsRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = Supabase.instance.client;
|
||||||
static const String _bucketName = 'documents';
|
|
||||||
static const String _tableName =
|
|
||||||
'attachment'; // Cambia col vero nome della tua tabella se diverso!
|
|
||||||
|
|
||||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||||
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
||||||
try {
|
try {
|
||||||
|
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
|
||||||
|
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
|
||||||
|
// assicurati di passargli solo il percorso interno.
|
||||||
final Uint8List bytes = await _supabase.storage
|
final Uint8List bytes = await _supabase.storage
|
||||||
.from(_bucketName)
|
.from('attachments') // <--- NOME DEL TUO BUCKET
|
||||||
.download(storagePath);
|
.download(storagePath);
|
||||||
|
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Impossibile scaricare il documento dal cloud: $e");
|
throw Exception("Impossibile scaricare il documento dal cloud: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RESTITUISCE IL NOME DELLA COLONNA DB IN BASE AL TIPO
|
|
||||||
String _getColumnNameForParent(AttachmentParentType parentType) {
|
|
||||||
switch (parentType) {
|
|
||||||
case AttachmentParentType.operation:
|
|
||||||
return 'operation_id';
|
|
||||||
case AttachmentParentType.ticket:
|
|
||||||
return 'ticket_id';
|
|
||||||
case AttachmentParentType.customer:
|
|
||||||
return 'customer_id';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RECUPERA I FILE IN TEMPO REALE
|
|
||||||
Stream<List<AttachmentModel>> getFilesStream(
|
|
||||||
String parentId,
|
|
||||||
AttachmentParentType parentType,
|
|
||||||
) {
|
|
||||||
final columnName = _getColumnNameForParent(parentType);
|
|
||||||
|
|
||||||
return _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.stream(primaryKey: ['id'])
|
|
||||||
.eq(columnName, parentId)
|
|
||||||
.map(
|
|
||||||
(listOfMaps) =>
|
|
||||||
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CARICA IL FILE NELLO STORAGE E LO REGISTRA NEL DB
|
|
||||||
Future<void> uploadAndRegisterFile({
|
|
||||||
required String parentId,
|
|
||||||
required AttachmentParentType parentType,
|
|
||||||
required PlatformFile pickedFile,
|
|
||||||
required String companyId,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
if (pickedFile.bytes == null) {
|
|
||||||
throw Exception(
|
|
||||||
"I bytes del file sono vuoti! Ricarica la pagina senza cache.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final extension = pickedFile.extension ?? pickedFile.name.split('.').last;
|
|
||||||
final cleanName = pickedFile.name
|
|
||||||
.replaceAll(RegExp(r'[^\w\s\.-]'), '')
|
|
||||||
.replaceAll(' ', '_');
|
|
||||||
|
|
||||||
// Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
|
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final storagePath =
|
|
||||||
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
|
|
||||||
|
|
||||||
// 1. Upload su Supabase Storage
|
|
||||||
await _supabase.storage
|
|
||||||
.from(_bucketName)
|
|
||||||
.uploadBinary(
|
|
||||||
storagePath,
|
|
||||||
pickedFile.bytes!,
|
|
||||||
fileOptions: FileOptions(contentType: _guessContentType(extension)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Creiamo la mappa per il DB dinamicamente
|
|
||||||
final Map<String, dynamic> insertData = {
|
|
||||||
'company_id': companyId,
|
|
||||||
'name': pickedFile.name.replaceAll('.$extension', ''),
|
|
||||||
'extension': extension,
|
|
||||||
'file_size': pickedFile.size,
|
|
||||||
'storage_path': storagePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inseriamo l'ID nella colonna giusta!
|
|
||||||
final columnName = _getColumnNameForParent(parentType);
|
|
||||||
insertData[columnName] = parentId;
|
|
||||||
|
|
||||||
// 3. Salviamo su Postgres
|
|
||||||
await _supabase.from(_tableName).insert(insertData);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Errore caricamento: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ELIMINA IL FILE (Scollegamento intelligente)
|
|
||||||
Future<void> deleteFiles({
|
|
||||||
required List<AttachmentModel> files,
|
|
||||||
required AttachmentParentType currentContextType,
|
|
||||||
}) async {
|
|
||||||
if (files.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (var file in files) {
|
|
||||||
if (file.id == null) continue;
|
|
||||||
|
|
||||||
// 1. Capiamo quali collegamenti ha questo file attualmente
|
|
||||||
final currentLinks = {
|
|
||||||
AttachmentParentType.operation: file.operationId,
|
|
||||||
AttachmentParentType.ticket: file.ticketId,
|
|
||||||
AttachmentParentType.customer: file.customerId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Simuliamo la rimozione del collegamento per il contesto attuale
|
|
||||||
currentLinks[currentContextType] = null;
|
|
||||||
|
|
||||||
// 3. Controlliamo se rimangono altri ID valorizzati
|
|
||||||
final hasOtherActiveLinks = currentLinks.values.any(
|
|
||||||
(id) => id != null && id.isNotEmpty,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasOtherActiveLinks) {
|
|
||||||
// A. Ci sono ancora altre entità che usano questo file!
|
|
||||||
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
|
|
||||||
await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.update({currentContextType.dbColumn: null})
|
|
||||||
.eq('id', file.id!);
|
|
||||||
} else {
|
|
||||||
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
|
|
||||||
await _supabase.from(_tableName).delete().eq('id', file.id!);
|
|
||||||
|
|
||||||
if (file.storagePath != null) {
|
|
||||||
await _supabase.storage.from(_bucketName).remove([
|
|
||||||
file.storagePath!,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Errore nell'eliminazione dei file: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RINOMINA UN FILE (Solo nel DB, non cambiamo il file fisico)
|
|
||||||
Future<void> renameAttachment(String fileId, String newName) async {
|
|
||||||
try {
|
|
||||||
await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.update({'name': newName})
|
|
||||||
.eq('id', fileId);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Errore nella rinomina del file: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ASSOCIA UN FILE A UN'ALTRA ENTITÀ (Modifica il record esistente)
|
|
||||||
Future<void> linkFileToEntity({
|
|
||||||
required String fileId,
|
|
||||||
required AttachmentParentType targetType,
|
|
||||||
required String targetId,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
|
||||||
await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.update({targetType.dbColumn: targetId})
|
|
||||||
.eq('id', fileId);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Errore nel collegamento del file: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper per indovinare il content-type base
|
|
||||||
String _guessContentType(String extension) {
|
|
||||||
switch (extension.toLowerCase()) {
|
|
||||||
case 'pdf':
|
|
||||||
return 'application/pdf';
|
|
||||||
case 'png':
|
|
||||||
return 'image/png';
|
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg':
|
|
||||||
return 'image/jpeg';
|
|
||||||
default:
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ class AttachmentModel extends Equatable {
|
|||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final String? customerId;
|
final String? customerId;
|
||||||
final String? operationId;
|
final String? operationId;
|
||||||
final String? ticketId;
|
|
||||||
final String name;
|
final String name;
|
||||||
final String extension;
|
final String extension;
|
||||||
final String? storagePath;
|
final String? storagePath;
|
||||||
@@ -20,7 +19,6 @@ class AttachmentModel extends Equatable {
|
|||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.customerId,
|
this.customerId,
|
||||||
this.operationId,
|
this.operationId,
|
||||||
this.ticketId,
|
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.extension,
|
required this.extension,
|
||||||
this.storagePath,
|
this.storagePath,
|
||||||
@@ -35,7 +33,6 @@ class AttachmentModel extends Equatable {
|
|||||||
createdAt,
|
createdAt,
|
||||||
customerId,
|
customerId,
|
||||||
operationId,
|
operationId,
|
||||||
ticketId,
|
|
||||||
name,
|
name,
|
||||||
extension,
|
extension,
|
||||||
storagePath,
|
storagePath,
|
||||||
@@ -62,7 +59,6 @@ class AttachmentModel extends Equatable {
|
|||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? customerId,
|
String? customerId,
|
||||||
String? operationId,
|
String? operationId,
|
||||||
String? ticketId,
|
|
||||||
String? name,
|
String? name,
|
||||||
String? extension,
|
String? extension,
|
||||||
String? storagePath,
|
String? storagePath,
|
||||||
@@ -74,7 +70,6 @@ class AttachmentModel extends Equatable {
|
|||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
customerId: customerId ?? this.customerId,
|
customerId: customerId ?? this.customerId,
|
||||||
operationId: operationId ?? this.operationId,
|
operationId: operationId ?? this.operationId,
|
||||||
ticketId: ticketId ?? this.ticketId,
|
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
extension: extension ?? this.extension,
|
extension: extension ?? this.extension,
|
||||||
storagePath: storagePath ?? this.storagePath,
|
storagePath: storagePath ?? this.storagePath,
|
||||||
@@ -91,7 +86,6 @@ class AttachmentModel extends Equatable {
|
|||||||
: null,
|
: null,
|
||||||
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?,
|
|
||||||
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?,
|
||||||
@@ -110,7 +104,6 @@ class AttachmentModel extends Equatable {
|
|||||||
'storage_path': storagePath,
|
'storage_path': storagePath,
|
||||||
'customer_id': customerId,
|
'customer_id': customerId,
|
||||||
'operation_id': operationId,
|
'operation_id': operationId,
|
||||||
'ticket_id': ticketId,
|
|
||||||
'file_size': fileSize,
|
'file_size': fileSize,
|
||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
};
|
};
|
||||||
|
|||||||
33
lib/features/company/bloc/company_bloc.dart
Normal file
33
lib/features/company/bloc/company_bloc.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/features/company/data/company_repository.dart';
|
||||||
|
import 'package:flux/features/company/models/company_model.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
part 'company_events.dart';
|
||||||
|
part 'company_state.dart';
|
||||||
|
|
||||||
|
class CompanyBloc extends Bloc<CompanyEvent, CompanyState> {
|
||||||
|
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
|
||||||
|
CompanyBloc() : super(const CompanyState(status: CompanyStatus.initial)) {
|
||||||
|
on<CreateCompanyRequested>((event, emit) async {
|
||||||
|
emit(const CompanyState(status: CompanyStatus.loading));
|
||||||
|
try {
|
||||||
|
final createdCompany = await _repository.createCompany(event.company);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CompanyStatus.success,
|
||||||
|
company: createdCompany,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CompanyStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/features/company/bloc/company_events.dart
Normal file
19
lib/features/company/bloc/company_events.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
part of 'company_bloc.dart';
|
||||||
|
|
||||||
|
// lib/blocs/company/company_event.dart
|
||||||
|
|
||||||
|
abstract class CompanyEvent extends Equatable {
|
||||||
|
const CompanyEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateCompanyRequested extends CompanyEvent {
|
||||||
|
final CompanyModel company;
|
||||||
|
|
||||||
|
const CreateCompanyRequested({required this.company});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [company];
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter/foundation.dart'; // Per kIsWeb
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/features/company/data/company_repository.dart';
|
|
||||||
import 'package:flux/features/company/models/company_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
|
|
||||||
part 'company_settings_state.dart';
|
|
||||||
|
|
||||||
class CompanySettingsCubit extends Cubit<CompanySettingsState> {
|
|
||||||
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
|
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
|
||||||
|
|
||||||
CompanySettingsCubit() : super(const CompanySettingsState());
|
|
||||||
|
|
||||||
void initSettings() {
|
|
||||||
final currentCompany = _sessionCubit.state.company;
|
|
||||||
if (currentCompany != null) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
company: currentCompany,
|
|
||||||
status: CompanySettingsStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateFields({
|
|
||||||
String? name,
|
|
||||||
String? vatId, // Modificato da vatNumber a vatId
|
|
||||||
String? fiscalCode, // Aggiunto
|
|
||||||
String? sdi, // Aggiunto
|
|
||||||
String? address,
|
|
||||||
String? city,
|
|
||||||
String? province, // Aggiunto
|
|
||||||
String? zipCode,
|
|
||||||
String? phone,
|
|
||||||
String? email,
|
|
||||||
String? ticketDisclaimer,
|
|
||||||
LabelFormat? labelFormat,
|
|
||||||
double? labelWidth,
|
|
||||||
double? labelHeight,
|
|
||||||
bool? isVertical,
|
|
||||||
}) {
|
|
||||||
if (state.company == null) return;
|
|
||||||
|
|
||||||
final updated = state.company!.copyWith(
|
|
||||||
name: name ?? state.company!.name,
|
|
||||||
vatId: vatId ?? state.company!.vatId,
|
|
||||||
fiscalCode: fiscalCode ?? state.company!.fiscalCode,
|
|
||||||
sdi: sdi ?? state.company!.sdi,
|
|
||||||
address: address ?? state.company!.address,
|
|
||||||
city: city ?? state.company!.city,
|
|
||||||
province: province ?? state.company!.province,
|
|
||||||
zipCode: zipCode ?? state.company!.zipCode,
|
|
||||||
phone: phone ?? state.company!.phone,
|
|
||||||
email: email ?? state.company!.email,
|
|
||||||
ticketDisclaimer: ticketDisclaimer ?? state.company!.ticketDisclaimer,
|
|
||||||
labelFormat: labelFormat ?? state.company!.labelFormat,
|
|
||||||
labelWidth: labelWidth ?? state.company!.labelWidth,
|
|
||||||
labelHeight: labelHeight ?? state.company!.labelHeight,
|
|
||||||
isLabelVertical: isVertical ?? state.company!.isLabelVertical,
|
|
||||||
);
|
|
||||||
emit(state.copyWith(company: updated));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveSettings() async {
|
|
||||||
if (state.company == null) return;
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: CompanySettingsStatus.saving, errorMessage: null),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Salva i dati su Supabase
|
|
||||||
final updatedCompany = await _repository.updateCompany(state.company!);
|
|
||||||
|
|
||||||
// 2. Aggiorna la sessione globale per riflettere i cambiamenti in tutta l'app
|
|
||||||
_sessionCubit.updateCurrentCompany(updatedCompany);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: CompanySettingsStatus.success,
|
|
||||||
company: updatedCompany,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: CompanySettingsStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metodo per gestire l'upload del logo
|
|
||||||
Future<void> uploadLogo(Uint8List bytes, String fileName) async {
|
|
||||||
if (state.company == null) return;
|
|
||||||
emit(state.copyWith(status: CompanySettingsStatus.uploadingLogo));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Usa il tuo repository per caricare il file nel bucket 'company_logos'
|
|
||||||
// Il file può essere Uint8List (se sei su Web) o File (se sei su Mobile/Desktop)
|
|
||||||
final publicUrl = await _repository.uploadCompanyLogo(
|
|
||||||
companyId: state.company!.id!,
|
|
||||||
fileBytes: bytes,
|
|
||||||
fileName: fileName,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedCompany = state.company!.copyWith(logoUrl: publicUrl);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
company: updatedCompany,
|
|
||||||
status: CompanySettingsStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chiamiamo il salvataggio per rendere definitivo l'URL nel record della compagnia
|
|
||||||
await saveSettings();
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: CompanySettingsStatus.failure,
|
|
||||||
errorMessage: "Errore caricamento logo: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
part of 'company_settings_cubit.dart';
|
|
||||||
|
|
||||||
class CompanySettingsState extends Equatable {
|
|
||||||
final CompanySettingsStatus status;
|
|
||||||
final CompanyModel? company;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
const CompanySettingsState({
|
|
||||||
this.status = CompanySettingsStatus.initial,
|
|
||||||
this.company,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
CompanySettingsState copyWith({
|
|
||||||
CompanySettingsStatus? status,
|
|
||||||
CompanyModel? company,
|
|
||||||
String? errorMessage,
|
|
||||||
}) {
|
|
||||||
return CompanySettingsState(
|
|
||||||
status: status ?? this.status,
|
|
||||||
company: company ?? this.company,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [status, company, errorMessage];
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CompanySettingsStatus {
|
|
||||||
initial,
|
|
||||||
ready,
|
|
||||||
saving,
|
|
||||||
uploadingLogo,
|
|
||||||
success,
|
|
||||||
failure,
|
|
||||||
}
|
|
||||||
26
lib/features/company/bloc/company_state.dart
Normal file
26
lib/features/company/bloc/company_state.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
part of 'company_bloc.dart';
|
||||||
|
|
||||||
|
enum CompanyStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class CompanyState extends Equatable {
|
||||||
|
final CompanyStatus status;
|
||||||
|
final String? errorMessage;
|
||||||
|
final CompanyModel? company;
|
||||||
|
|
||||||
|
const CompanyState({required this.status, this.errorMessage, this.company});
|
||||||
|
|
||||||
|
CompanyState copyWith({
|
||||||
|
CompanyStatus? status,
|
||||||
|
String? errorMessage,
|
||||||
|
CompanyModel? company,
|
||||||
|
}) {
|
||||||
|
return CompanyState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
company: company ?? this.company,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, errorMessage, company];
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/company_model.dart';
|
import '../models/company_model.dart';
|
||||||
|
|
||||||
@@ -23,62 +21,6 @@ class CompanyRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CompanyModel> updateCompany(CompanyModel company) async {
|
|
||||||
try {
|
|
||||||
final response = await _supabase
|
|
||||||
.from('company')
|
|
||||||
.update(company.toMap())
|
|
||||||
.eq('id', company.id!)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return CompanyModel.fromMap(response);
|
|
||||||
} on PostgrestException catch (e) {
|
|
||||||
throw e.message;
|
|
||||||
} catch (e) {
|
|
||||||
throw e.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> uploadCompanyLogo({
|
|
||||||
required String companyId,
|
|
||||||
required Uint8List fileBytes,
|
|
||||||
required String fileName,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
// 1. Prepariamo il path.
|
|
||||||
// Organizziamo per companyId e aggiungiamo un timestamp per evitare cache del browser
|
|
||||||
// quando l'utente cambia logo più volte.
|
|
||||||
final extension = fileName.split('.').last;
|
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final filePath = '$companyId/logo_$timestamp.$extension';
|
|
||||||
|
|
||||||
// 2. Caricamento fisico dei bytes
|
|
||||||
// Usiamo uploadBinary che è perfetto per Uint8List
|
|
||||||
await _supabase.storage
|
|
||||||
.from('company_logos')
|
|
||||||
.uploadBinary(
|
|
||||||
filePath,
|
|
||||||
fileBytes,
|
|
||||||
fileOptions: const FileOptions(
|
|
||||||
cacheControl: '3600',
|
|
||||||
upsert:
|
|
||||||
true, // Se esiste già un file con lo stesso nome, lo sovrascrive
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Otteniamo l'URL pubblico.
|
|
||||||
// Nota: il bucket 'company_logos' deve essere impostato come PUBLIC su Supabase
|
|
||||||
final String publicUrl = _supabase.storage
|
|
||||||
.from('company_logos')
|
|
||||||
.getPublicUrl(filePath);
|
|
||||||
|
|
||||||
return publicUrl;
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Errore durante l'upload del logo: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<CompanyModel?> getCompany() async {
|
Future<CompanyModel?> getCompany() async {
|
||||||
try {
|
try {
|
||||||
final userId = _supabase.auth.currentUser?.id;
|
final userId = _supabase.auth.currentUser?.id;
|
||||||
|
|||||||
@@ -35,21 +35,6 @@ enum SubscriptionStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LabelFormat {
|
|
||||||
none,
|
|
||||||
small_62x29,
|
|
||||||
medium_54x101,
|
|
||||||
large_102x152,
|
|
||||||
custom;
|
|
||||||
|
|
||||||
static LabelFormat fromString(String? value) {
|
|
||||||
return LabelFormat.values.firstWhere(
|
|
||||||
(e) => e.name == value,
|
|
||||||
orElse: () => LabelFormat.none,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// IL MODELLO ESATTO
|
// IL MODELLO ESATTO
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -68,14 +53,8 @@ class CompanyModel extends Equatable {
|
|||||||
final String vatId;
|
final String vatId;
|
||||||
final String fiscalCode;
|
final String fiscalCode;
|
||||||
final String sdi;
|
final String sdi;
|
||||||
final String? phone;
|
final String companyLogo;
|
||||||
final String? email;
|
|
||||||
final String? logoUrl;
|
|
||||||
final String? ticketDisclaimer;
|
|
||||||
final LabelFormat labelFormat;
|
|
||||||
final double? labelWidth;
|
|
||||||
final double? labelHeight;
|
|
||||||
final bool isLabelVertical;
|
|
||||||
// Stato Pagamenti (Ibride: manuale + Stripe)
|
// Stato Pagamenti (Ibride: manuale + Stripe)
|
||||||
final bool isPaid;
|
final bool isPaid;
|
||||||
final DateTime? paymentExpiration;
|
final DateTime? paymentExpiration;
|
||||||
@@ -99,14 +78,7 @@ class CompanyModel extends Equatable {
|
|||||||
required this.vatId,
|
required this.vatId,
|
||||||
required this.fiscalCode,
|
required this.fiscalCode,
|
||||||
required this.sdi,
|
required this.sdi,
|
||||||
this.phone,
|
this.companyLogo = '',
|
||||||
this.email,
|
|
||||||
this.logoUrl,
|
|
||||||
this.ticketDisclaimer,
|
|
||||||
this.labelFormat = LabelFormat.none,
|
|
||||||
this.labelWidth,
|
|
||||||
this.labelHeight,
|
|
||||||
this.isLabelVertical = false,
|
|
||||||
this.isPaid = false,
|
this.isPaid = false,
|
||||||
this.paymentExpiration,
|
this.paymentExpiration,
|
||||||
this.subscriptionTier = SubscriptionTier.free,
|
this.subscriptionTier = SubscriptionTier.free,
|
||||||
@@ -128,14 +100,7 @@ class CompanyModel extends Equatable {
|
|||||||
String? vatId,
|
String? vatId,
|
||||||
String? fiscalCode,
|
String? fiscalCode,
|
||||||
String? sdi,
|
String? sdi,
|
||||||
String? logoUrl,
|
String? companyLogo,
|
||||||
String? ticketDisclaimer,
|
|
||||||
LabelFormat? labelFormat,
|
|
||||||
double? labelWidth,
|
|
||||||
double? labelHeight,
|
|
||||||
bool? isLabelVertical,
|
|
||||||
String? phone,
|
|
||||||
String? email,
|
|
||||||
bool? isPaid,
|
bool? isPaid,
|
||||||
DateTime? paymentExpiration,
|
DateTime? paymentExpiration,
|
||||||
SubscriptionTier? subscriptionTier,
|
SubscriptionTier? subscriptionTier,
|
||||||
@@ -156,14 +121,7 @@ class CompanyModel extends Equatable {
|
|||||||
vatId: vatId ?? this.vatId,
|
vatId: vatId ?? this.vatId,
|
||||||
fiscalCode: fiscalCode ?? this.fiscalCode,
|
fiscalCode: fiscalCode ?? this.fiscalCode,
|
||||||
sdi: sdi ?? this.sdi,
|
sdi: sdi ?? this.sdi,
|
||||||
logoUrl: logoUrl ?? this.logoUrl,
|
companyLogo: companyLogo ?? this.companyLogo,
|
||||||
phone: phone ?? this.phone,
|
|
||||||
email: email ?? this.email,
|
|
||||||
ticketDisclaimer: ticketDisclaimer ?? this.ticketDisclaimer,
|
|
||||||
labelFormat: labelFormat ?? this.labelFormat,
|
|
||||||
labelWidth: labelWidth ?? this.labelWidth,
|
|
||||||
labelHeight: labelHeight ?? this.labelHeight,
|
|
||||||
isLabelVertical: isLabelVertical ?? this.isLabelVertical,
|
|
||||||
isPaid: isPaid ?? this.isPaid,
|
isPaid: isPaid ?? this.isPaid,
|
||||||
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
|
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
|
||||||
subscriptionTier: subscriptionTier ?? this.subscriptionTier,
|
subscriptionTier: subscriptionTier ?? this.subscriptionTier,
|
||||||
@@ -205,18 +163,7 @@ class CompanyModel extends Equatable {
|
|||||||
vatId: map['vat_id'] ?? '',
|
vatId: map['vat_id'] ?? '',
|
||||||
fiscalCode: map['fiscal_code'] ?? '',
|
fiscalCode: map['fiscal_code'] ?? '',
|
||||||
sdi: map['sdi'] ?? '',
|
sdi: map['sdi'] ?? '',
|
||||||
logoUrl: map['logo_url'],
|
companyLogo: map['company_logo'] ?? '',
|
||||||
phone: map['phone'] ?? '',
|
|
||||||
email: map['email'] ?? '',
|
|
||||||
ticketDisclaimer: map['ticket_disclaimer'],
|
|
||||||
labelFormat: LabelFormat.fromString(map['label_format']),
|
|
||||||
labelWidth: map['label_width'] != null
|
|
||||||
? (map['label_width'] as num).toDouble()
|
|
||||||
: null,
|
|
||||||
labelHeight: map['label_height'] != null
|
|
||||||
? (map['label_height'] as num).toDouble()
|
|
||||||
: null,
|
|
||||||
isLabelVertical: map['is_label_vertical'] ?? false,
|
|
||||||
isPaid: map['is_paid'] ?? false,
|
isPaid: map['is_paid'] ?? false,
|
||||||
paymentExpiration: map['payment_expiration'] != null
|
paymentExpiration: map['payment_expiration'] != null
|
||||||
? DateTime.tryParse(map['payment_expiration'])
|
? DateTime.tryParse(map['payment_expiration'])
|
||||||
@@ -246,14 +193,7 @@ class CompanyModel extends Equatable {
|
|||||||
'vat_id': vatId,
|
'vat_id': vatId,
|
||||||
'fiscal_code': fiscalCode,
|
'fiscal_code': fiscalCode,
|
||||||
'sdi': sdi,
|
'sdi': sdi,
|
||||||
'logo_url': logoUrl,
|
'company_logo': companyLogo,
|
||||||
'phone': phone,
|
|
||||||
'email': email,
|
|
||||||
'ticket_disclaimer': ticketDisclaimer,
|
|
||||||
'label_format': labelFormat.name,
|
|
||||||
'label_width': labelWidth,
|
|
||||||
'label_height': labelHeight,
|
|
||||||
'is_label_vertical': isLabelVertical,
|
|
||||||
'is_paid': isPaid,
|
'is_paid': isPaid,
|
||||||
if (paymentExpiration != null)
|
if (paymentExpiration != null)
|
||||||
'payment_expiration': paymentExpiration!.toIso8601String(),
|
'payment_expiration': paymentExpiration!.toIso8601String(),
|
||||||
@@ -281,14 +221,7 @@ class CompanyModel extends Equatable {
|
|||||||
vatId,
|
vatId,
|
||||||
fiscalCode,
|
fiscalCode,
|
||||||
sdi,
|
sdi,
|
||||||
logoUrl,
|
companyLogo,
|
||||||
phone,
|
|
||||||
email,
|
|
||||||
ticketDisclaimer,
|
|
||||||
labelFormat,
|
|
||||||
labelWidth,
|
|
||||||
labelHeight,
|
|
||||||
isLabelVertical,
|
|
||||||
isPaid,
|
isPaid,
|
||||||
paymentExpiration,
|
paymentExpiration,
|
||||||
subscriptionTier,
|
subscriptionTier,
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
|
|
||||||
import 'package:flux/features/company/models/company_model.dart';
|
|
||||||
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
|
|
||||||
import 'package:flux/features/settings/document_sequence/ui/document_sequence_section.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
|
|
||||||
class CompanySettingsScreen extends StatefulWidget {
|
|
||||||
const CompanySettingsScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CompanySettingsScreen> createState() => _CompanySettingsScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
final _nameCtrl = TextEditingController();
|
|
||||||
final _vatCtrl = TextEditingController();
|
|
||||||
final _fiscalCodeCtrl = TextEditingController(); // Nuovo
|
|
||||||
final _sdiCtrl = TextEditingController(); // Nuovo
|
|
||||||
final _addressCtrl = TextEditingController();
|
|
||||||
final _cityCtrl = TextEditingController();
|
|
||||||
final _provinceCtrl = TextEditingController(); // Nuovo
|
|
||||||
final _zipCtrl = TextEditingController();
|
|
||||||
final _phoneCtrl = TextEditingController();
|
|
||||||
final _emailCtrl = TextEditingController();
|
|
||||||
final _disclaimerCtrl = TextEditingController();
|
|
||||||
|
|
||||||
bool _isInitialized = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final cubit = context.read<CompanySettingsCubit>();
|
|
||||||
cubit.initSettings();
|
|
||||||
if (cubit.state.status == CompanySettingsStatus.ready &&
|
|
||||||
cubit.state.company != null) {
|
|
||||||
_syncControllers(cubit.state.company!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameCtrl.dispose();
|
|
||||||
_vatCtrl.dispose();
|
|
||||||
_fiscalCodeCtrl.dispose(); // Nuovo
|
|
||||||
_sdiCtrl.dispose(); // Nuovo
|
|
||||||
_addressCtrl.dispose();
|
|
||||||
_cityCtrl.dispose();
|
|
||||||
_provinceCtrl.dispose(); // Nuovo
|
|
||||||
_zipCtrl.dispose();
|
|
||||||
_phoneCtrl.dispose();
|
|
||||||
_emailCtrl.dispose();
|
|
||||||
_disclaimerCtrl.dispose();
|
|
||||||
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncControllers(CompanyModel company) {
|
|
||||||
if (_nameCtrl.text.isEmpty) _nameCtrl.text = company.name;
|
|
||||||
if (_vatCtrl.text.isEmpty) _vatCtrl.text = company.vatId;
|
|
||||||
if (_fiscalCodeCtrl.text.isEmpty) {
|
|
||||||
_fiscalCodeCtrl.text = company.fiscalCode; // Nuovo
|
|
||||||
}
|
|
||||||
if (_sdiCtrl.text.isEmpty) _sdiCtrl.text = company.sdi; // Nuovo
|
|
||||||
if (_provinceCtrl.text.isEmpty) {
|
|
||||||
_provinceCtrl.text = company.province; // Nuovo
|
|
||||||
}
|
|
||||||
if (_addressCtrl.text.isEmpty) _addressCtrl.text = company.address;
|
|
||||||
if (_cityCtrl.text.isEmpty) _cityCtrl.text = company.city;
|
|
||||||
if (_zipCtrl.text.isEmpty) _zipCtrl.text = company.zipCode;
|
|
||||||
if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = company.phone ?? '';
|
|
||||||
if (_emailCtrl.text.isEmpty) _emailCtrl.text = company.email ?? '';
|
|
||||||
_isInitialized = true;
|
|
||||||
if (_disclaimerCtrl.text.isEmpty) {
|
|
||||||
_disclaimerCtrl.text = company.ticketDisclaimer ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _flushToCubit() {
|
|
||||||
context.read<CompanySettingsCubit>().updateFields(
|
|
||||||
name: _nameCtrl.text,
|
|
||||||
vatId: _vatCtrl.text,
|
|
||||||
fiscalCode: _fiscalCodeCtrl.text, // Nuovo
|
|
||||||
sdi: _sdiCtrl.text, // Nuovo
|
|
||||||
province: _provinceCtrl.text,
|
|
||||||
address: _addressCtrl.text,
|
|
||||||
city: _cityCtrl.text,
|
|
||||||
zipCode: _zipCtrl.text,
|
|
||||||
phone: _phoneCtrl.text,
|
|
||||||
email: _emailCtrl.text,
|
|
||||||
ticketDisclaimer: _disclaimerCtrl.text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickAndUploadLogo() async {
|
|
||||||
final picker = ImagePicker();
|
|
||||||
final companySettingsCubit = context.read<CompanySettingsCubit>();
|
|
||||||
|
|
||||||
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
|
||||||
|
|
||||||
if (pickedFile != null && mounted) {
|
|
||||||
// Passiamo i bytes per compatibilità totale con Flutter Web
|
|
||||||
final bytes = await pickedFile.readAsBytes();
|
|
||||||
companySettingsCubit.uploadLogo(bytes, pickedFile.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onLabelFormatChanged(LabelFormat selectedFormat) {
|
|
||||||
double? w;
|
|
||||||
double? h;
|
|
||||||
|
|
||||||
switch (selectedFormat) {
|
|
||||||
case LabelFormat.small_62x29:
|
|
||||||
w = 62.0;
|
|
||||||
h = 29.0;
|
|
||||||
break;
|
|
||||||
case LabelFormat.medium_54x101:
|
|
||||||
w = 54.0;
|
|
||||||
h = 101.0;
|
|
||||||
break;
|
|
||||||
case LabelFormat.large_102x152:
|
|
||||||
w = 102.0;
|
|
||||||
h = 152.0;
|
|
||||||
break;
|
|
||||||
case LabelFormat.custom:
|
|
||||||
case LabelFormat.none:
|
|
||||||
// Lasciamo i valori null o quelli vecchi
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.read<CompanySettingsCubit>().updateFields(
|
|
||||||
labelFormat: selectedFormat,
|
|
||||||
labelWidth: w,
|
|
||||||
labelHeight: h,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Impostazioni Azienda')),
|
|
||||||
body: BlocConsumer<CompanySettingsCubit, CompanySettingsState>(
|
|
||||||
listener: (context, state) {
|
|
||||||
if (state.status == CompanySettingsStatus.ready && !_isInitialized) {
|
|
||||||
_syncControllers(state.company!);
|
|
||||||
}
|
|
||||||
if (state.status == CompanySettingsStatus.success) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Impostazioni salvate con successo!'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.status == CompanySettingsStatus.failure) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(state.errorMessage ?? 'Errore'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.company == null) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final company = state.company!;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 800),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
children: [
|
|
||||||
// --- SEZIONE LOGO ---
|
|
||||||
Center(
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.bottomRight,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 120,
|
|
||||||
width: 120,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: theme.dividerColor),
|
|
||||||
image: company.logoUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: NetworkImage(company.logoUrl!),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: company.logoUrl == null
|
|
||||||
? const Icon(
|
|
||||||
Icons.business,
|
|
||||||
size: 50,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (state.status ==
|
|
||||||
CompanySettingsStatus.uploadingLogo)
|
|
||||||
const Positioned.fill(
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
FloatingActionButton.small(
|
|
||||||
onPressed:
|
|
||||||
state.status ==
|
|
||||||
CompanySettingsStatus.uploadingLogo
|
|
||||||
? null
|
|
||||||
: _pickAndUploadLogo,
|
|
||||||
child: const Icon(Icons.camera_alt),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// --- SEZIONE DATI PRINCIPALI ---
|
|
||||||
Text(
|
|
||||||
'Dati Legali',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _nameCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Ragione Sociale',
|
|
||||||
prefixIcon: Icon(Icons.badge),
|
|
||||||
),
|
|
||||||
validator: (val) => val == null || val.isEmpty
|
|
||||||
? 'Campo obbligatorio'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _vatCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Partita IVA',
|
|
||||||
prefixIcon: Icon(Icons.receipt_long),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _fiscalCodeCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Codice Fiscale',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _sdiCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Codice SDI',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// --- SEZIONE INDIRIZZO E CONTATTI ---
|
|
||||||
Text(
|
|
||||||
'Sede e Contatti',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _addressCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Indirizzo (Via e numero civico)',
|
|
||||||
prefixIcon: Icon(Icons.location_on),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _cityCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Città',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _provinceCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Provincia (es. MI)',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _zipCtrl,
|
|
||||||
decoration: const InputDecoration(labelText: 'CAP'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _phoneCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Telefono',
|
|
||||||
prefixIcon: Icon(Icons.phone),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _emailCtrl,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Email',
|
|
||||||
prefixIcon: Icon(Icons.email),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
BlocProvider(
|
|
||||||
create: (context) =>
|
|
||||||
DocumentSequenceCubit(state.company!.id!)
|
|
||||||
..loadSequences(),
|
|
||||||
child: const DocumentSequenceSection(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Sezione Disclaimer
|
|
||||||
Text(
|
|
||||||
"Note Legali Ricevuta",
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(
|
|
||||||
controller: _disclaimerCtrl,
|
|
||||||
maxLines: 5,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText:
|
|
||||||
"Inserisci qui la liberatoria legale che apparirà sulla ricevuta dei ticket...",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (val) => context
|
|
||||||
.read<CompanySettingsCubit>()
|
|
||||||
.updateFields(ticketDisclaimer: val),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Sezione Etichette
|
|
||||||
Text(
|
|
||||||
"Configurazione Etichette",
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
DropdownButtonFormField<LabelFormat>(
|
|
||||||
initialValue: company.labelFormat,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
prefixIcon: Icon(Icons.label_outline),
|
|
||||||
labelText: "Formato Stampa Etichetta",
|
|
||||||
),
|
|
||||||
items: LabelFormat.values
|
|
||||||
.map(
|
|
||||||
(f) => DropdownMenuItem(
|
|
||||||
value: f,
|
|
||||||
child: Text(
|
|
||||||
f.name.replaceAll('_', ' ').toUpperCase(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val != null) {
|
|
||||||
_onLabelFormatChanged(val);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
|
|
||||||
// --- PULSANTE SALVATAGGIO ---
|
|
||||||
SizedBox(
|
|
||||||
height: 50,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: state.status == CompanySettingsStatus.saving
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
_flushToCubit();
|
|
||||||
context
|
|
||||||
.read<CompanySettingsCubit>()
|
|
||||||
.saveSettings();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: state.status == CompanySettingsStatus.saving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.white,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.save),
|
|
||||||
label: const Text(
|
|
||||||
'Salva Impostazioni',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
328
lib/features/company/ui/create_company_screen.dart
Normal file
328
lib/features/company/ui/create_company_screen.dart
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
|
import 'package:flux/features/company/bloc/company_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/theme/theme.dart';
|
||||||
|
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||||
|
import 'package:flux/features/company/models/company_model.dart';
|
||||||
|
|
||||||
|
class CreateCompanyScreen extends StatefulWidget {
|
||||||
|
const CreateCompanyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CreateCompanyScreen> createState() => _CreateCompanyScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// lib/ui/setup/create_company_screen.dart
|
||||||
|
|
||||||
|
class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controller per i campi obbligatori
|
||||||
|
final _ragioneSocialeController = TextEditingController();
|
||||||
|
final _indirizzoController = TextEditingController();
|
||||||
|
final _capController = TextEditingController();
|
||||||
|
final _cittaController = TextEditingController();
|
||||||
|
final _provinciaController = TextEditingController();
|
||||||
|
final _pIvaController = TextEditingController();
|
||||||
|
final _cfController = TextEditingController();
|
||||||
|
final _univocoController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Ricordati sempre di chiuderli!
|
||||||
|
_ragioneSocialeController.dispose();
|
||||||
|
_indirizzoController.dispose();
|
||||||
|
_capController.dispose();
|
||||||
|
_cittaController.dispose();
|
||||||
|
_provinciaController.dispose();
|
||||||
|
_pIvaController.dispose();
|
||||||
|
_cfController.dispose();
|
||||||
|
_univocoController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSave() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc
|
||||||
|
final userId = context.read<SessionCubit>().state.user!.id;
|
||||||
|
|
||||||
|
final company = CompanyModel(
|
||||||
|
userId: userId,
|
||||||
|
name: _ragioneSocialeController.text.trim(),
|
||||||
|
address: _indirizzoController.text.trim(),
|
||||||
|
zipCode: _capController.text.trim(),
|
||||||
|
city: _cittaController.text.trim(),
|
||||||
|
province: _provinciaController.text.trim(),
|
||||||
|
vatId: _pIvaController.text.trim(),
|
||||||
|
fiscalCode: _cfController.text.trim(),
|
||||||
|
sdi: _univocoController.text.trim().toUpperCase(),
|
||||||
|
// Gli altri campi hanno i default nel modello
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spariamo l'evento al Bloc
|
||||||
|
context.read<CompanyBloc>().add(CreateCompanyRequested(company: company));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
// Qui chiami il tuo Bloc dell'autenticazione per fare logout
|
||||||
|
// Esempio se hai un AuthBloc o SessionBloc:
|
||||||
|
//context.read<AuthBloc>().add(LogoutRequested());
|
||||||
|
|
||||||
|
// Se vuoi solo tornare brutalmente alla login per testare il logo:
|
||||||
|
// Navigator.of(context).pushReplacementNamed('/login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocConsumer<CompanyBloc, CompanyState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == CompanyStatus.success && state.company != null) {
|
||||||
|
// 1. Aggiorniamo la singleton con i dati reali (ID incluso)
|
||||||
|
//GetIt.I.get<AppSettings>().setCurrentCompany(state.company);
|
||||||
|
|
||||||
|
// 2. Notifichiamo il SessionBloc per cambiare pagina
|
||||||
|
//context.read<SessionCubit>().add(AppStarted());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == CompanyStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
state.errorMessage ?? context.l10n.commonSavingError,
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// --- SEZIONE 1: IDENTITÀ FISCALE ---
|
||||||
|
_SectionTitle(
|
||||||
|
title: context.l10n.createCompanyScreenFiscalData,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FluxTextField(
|
||||||
|
label: context.l10n.createCompanyScreenCompanyName,
|
||||||
|
icon: Icons.business,
|
||||||
|
controller: _ragioneSocialeController,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FluxTextField(
|
||||||
|
label: context.l10n.createCompanyScreenVatId,
|
||||||
|
icon: Icons.numbers,
|
||||||
|
controller: _pIvaController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FluxTextField(
|
||||||
|
label: context.l10n.createCompanyScreenFiscalCode,
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
controller: _cfController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FluxTextField(
|
||||||
|
label: context.l10n.createCompanyScreenSdiPec,
|
||||||
|
icon: Icons.send_and_archive_outlined,
|
||||||
|
controller: _univocoController,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// --- SEZIONE 2: SEDE LEGALE ---
|
||||||
|
_SectionTitle(
|
||||||
|
title:
|
||||||
|
context.l10n.createCompanyScreenCompanyLegalAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FluxTextField(
|
||||||
|
label: context.l10n.commonAddress,
|
||||||
|
icon: Icons.home_work_outlined,
|
||||||
|
controller: _indirizzoController,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FluxTextField(
|
||||||
|
label: context.l10n.commonCity,
|
||||||
|
icon: Icons.location_city,
|
||||||
|
controller: _cittaController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FluxTextField(
|
||||||
|
label: context.l10n.commonZipCode,
|
||||||
|
icon: Icons.map_outlined,
|
||||||
|
controller: _capController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FluxTextField(
|
||||||
|
label: context.l10n.commonProvince,
|
||||||
|
icon: Icons.explore_outlined,
|
||||||
|
controller: _provinciaController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// --- SEZIONE 3: LOGO AZIENDALE ---
|
||||||
|
_SectionTitle(title: 'BRANDING'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildLogoPicker(context),
|
||||||
|
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
|
// --- BOTTONE INVIO ---
|
||||||
|
_buildSubmitButton(context, state),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder per il futuro caricamento logo
|
||||||
|
Widget _buildLogoPicker(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.accent.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
// Bordo continuo ma sottile e semitrasparente per un look pulito
|
||||||
|
border: Border.all(
|
||||||
|
color: context.accent.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
context.l10n.createCompanyScreenUploadLogo,
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.primaryText,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.l10n.createCompanyScreenWillBeUsedForReceipts,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: context.secondaryText, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmitButton(BuildContext context, CompanyState state) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: state.status == CompanyStatus.loading
|
||||||
|
? null
|
||||||
|
: () => _onSave(),
|
||||||
|
child: state.status == CompanyStatus.loading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: Text(context.l10n.createCompanyScreenSaveCompany),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.accent.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.domain_add_rounded,
|
||||||
|
color: context.accent,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
context.l10n.createCompanyScreenSetupYourCompany,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.secondaryText,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget di supporto per i titoli delle sezioni
|
||||||
|
class _SectionTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
const _SectionTitle({required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.accent,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
lib/features/customers/blocs/customer_files_bloc.dart
Normal file
139
lib/features/customers/blocs/customer_files_bloc.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
|
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
part 'customer_files_events.dart';
|
||||||
|
part 'customer_files_state.dart';
|
||||||
|
|
||||||
|
class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
|
||||||
|
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
|
||||||
|
final String customerId;
|
||||||
|
CustomerFilesBloc(this.customerId)
|
||||||
|
: super(const CustomerFilesState(status: CustomerFilesStatus.initial)) {
|
||||||
|
on<LoadCustomerFilesEvent>(_loadCustomerFiles);
|
||||||
|
on<UploadCustomerFileEvent>(_uploadCustomerFile);
|
||||||
|
on<UploadMultipleCustomerFilesEvent>(_uploadMultipleCustomerFiles);
|
||||||
|
on<DeleteCustomerFilesEvent>(_deleteCustomerFiles);
|
||||||
|
on<ToggleCustomerFileSelectionEvent>(_toggleCustomerFileSelection);
|
||||||
|
}
|
||||||
|
void _loadCustomerFiles(
|
||||||
|
LoadCustomerFilesEvent event,
|
||||||
|
Emitter<CustomerFilesState> emit,
|
||||||
|
) async {
|
||||||
|
await emit.forEach<List<AttachmentModel>>(
|
||||||
|
_repository.getCustomerFilesStream(customerId),
|
||||||
|
onData: (customerFiles) => CustomerFilesState(
|
||||||
|
status: CustomerFilesStatus.success,
|
||||||
|
customerFiles: customerFiles,
|
||||||
|
),
|
||||||
|
onError: (error, stackTrace) => CustomerFilesState(
|
||||||
|
status: CustomerFilesStatus.failure,
|
||||||
|
error: error.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadCustomerFile(
|
||||||
|
UploadCustomerFileEvent event,
|
||||||
|
Emitter<CustomerFilesState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: CustomerFilesStatus.uploading));
|
||||||
|
if (event.pickedFile != null) {
|
||||||
|
try {
|
||||||
|
await _repository.uploadAndRegisterFile(
|
||||||
|
customerId: customerId,
|
||||||
|
pickedFile: event.pickedFile!,
|
||||||
|
);
|
||||||
|
emit(state.copyWith(status: CustomerFilesStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CustomerFilesStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _uploadMultipleCustomerFiles(
|
||||||
|
UploadMultipleCustomerFilesEvent event,
|
||||||
|
Emitter<CustomerFilesState> emit,
|
||||||
|
) async {
|
||||||
|
if (event.files.isEmpty) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CustomerFilesStatus.failure,
|
||||||
|
error: "Nessun file selezionato",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null));
|
||||||
|
try {
|
||||||
|
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
|
||||||
|
final List<Future<void>> uploadTasks = [];
|
||||||
|
for (var file in event.files) {
|
||||||
|
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
|
||||||
|
uploadTasks.add(
|
||||||
|
_repository.uploadAndRegisterFile(
|
||||||
|
customerId: customerId,
|
||||||
|
pickedFile: file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 3. ESECUZIONE PARALLELA!
|
||||||
|
// Aspettiamo che tutti i file siano caricati contemporaneamente.
|
||||||
|
await Future.wait(uploadTasks);
|
||||||
|
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
|
||||||
|
emit(state.copyWith(status: CustomerFilesStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
// Se anche un solo file fallisce, catturiamo l'errore
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CustomerFilesStatus.failure,
|
||||||
|
error: "Errore durante l'upload multiplo: $e",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteCustomerFiles(
|
||||||
|
DeleteCustomerFilesEvent event,
|
||||||
|
Emitter<CustomerFilesState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: CustomerFilesStatus.loading));
|
||||||
|
try {
|
||||||
|
await _repository.deleteDocuments(state.selectedFiles);
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CustomerFilesStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleCustomerFileSelection(
|
||||||
|
ToggleCustomerFileSelectionEvent event,
|
||||||
|
Emitter<CustomerFilesState> emit,
|
||||||
|
) {
|
||||||
|
List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
|
||||||
|
if (selectedFiles.contains(event.file)) {
|
||||||
|
selectedFiles.remove(event.file);
|
||||||
|
} else {
|
||||||
|
selectedFiles.add(event.file);
|
||||||
|
}
|
||||||
|
emit(state.copyWith(selectedFiles: selectedFiles));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/features/customers/blocs/customer_files_events.dart
Normal file
30
lib/features/customers/blocs/customer_files_events.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
part of 'customer_files_bloc.dart';
|
||||||
|
|
||||||
|
abstract class CustomerFilesEvent extends Equatable {
|
||||||
|
const CustomerFilesEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadCustomerFilesEvent extends CustomerFilesEvent {}
|
||||||
|
|
||||||
|
class UploadCustomerFileEvent extends CustomerFilesEvent {
|
||||||
|
final PlatformFile? pickedFile;
|
||||||
|
final File? photo;
|
||||||
|
const UploadCustomerFileEvent({this.pickedFile, this.photo});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
|
||||||
|
final List<PlatformFile> files;
|
||||||
|
const UploadMultipleCustomerFilesEvent(this.files);
|
||||||
|
@override
|
||||||
|
List<Object> get props => [files];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
|
||||||
|
|
||||||
|
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
|
||||||
|
final AttachmentModel file;
|
||||||
|
const ToggleCustomerFileSelectionEvent(this.file);
|
||||||
|
}
|
||||||
34
lib/features/customers/blocs/customer_files_state.dart
Normal file
34
lib/features/customers/blocs/customer_files_state.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
part of 'customer_files_bloc.dart';
|
||||||
|
|
||||||
|
enum CustomerFilesStatus { initial, loading, uploading, success, failure }
|
||||||
|
|
||||||
|
class CustomerFilesState extends Equatable {
|
||||||
|
const CustomerFilesState({
|
||||||
|
required this.status,
|
||||||
|
this.error,
|
||||||
|
this.customerFiles = const [],
|
||||||
|
this.selectedFiles = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final CustomerFilesStatus status;
|
||||||
|
final String? error;
|
||||||
|
final List<AttachmentModel> customerFiles;
|
||||||
|
final List<AttachmentModel> selectedFiles;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, error, customerFiles, selectedFiles];
|
||||||
|
|
||||||
|
CustomerFilesState copyWith({
|
||||||
|
CustomerFilesStatus? status,
|
||||||
|
String? error,
|
||||||
|
List<AttachmentModel>? customerFiles,
|
||||||
|
List<AttachmentModel>? selectedFiles,
|
||||||
|
}) {
|
||||||
|
return CustomerFilesState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
error: error,
|
||||||
|
customerFiles: customerFiles ?? this.customerFiles,
|
||||||
|
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,9 @@ import 'package:flux/core/theme/theme.dart';
|
|||||||
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||||
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
|
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
|
|
||||||
class CustomerDetailScreen extends StatefulWidget {
|
class CustomerDetailScreen extends StatefulWidget {
|
||||||
final CustomerModel customer;
|
final CustomerModel customer;
|
||||||
@@ -27,13 +26,11 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _loadFiles() {
|
void _loadFiles() {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
|
||||||
LoadAttachmentsEvent(parentId: widget.customer.id),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickAndUpload() async {
|
Future<void> _pickAndUpload() async {
|
||||||
AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
|
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
|
||||||
|
|
||||||
// Chiamata statica pulita
|
// Chiamata statica pulita
|
||||||
FilePickerResult? result = await FilePicker.pickFiles(
|
FilePickerResult? result = await FilePicker.pickFiles(
|
||||||
@@ -43,18 +40,17 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
for (var pickedFile in result.files) {
|
||||||
try {
|
try {
|
||||||
attachmentsBloc.add(
|
customerFilesBloc.add(
|
||||||
UploadAttachmentsEvent(
|
UploadCustomerFileEvent(pickedFile: pickedFile),
|
||||||
pickedFiles: result.files,
|
|
||||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
|
||||||
).showSnackBar(SnackBar(content: Text("$e")));
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +143,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentSection() {
|
Widget _buildDocumentSection() {
|
||||||
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -217,9 +213,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (state.status == AttachmentsStatus.loading)
|
if (state.status == CustomerFilesStatus.loading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (state.allFiles.isEmpty)
|
else if (state.customerFiles.isEmpty)
|
||||||
const Center(child: Text("Nessun documento presente"))
|
const Center(child: Text("Nessun documento presente"))
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -230,9 +226,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.2,
|
childAspectRatio: 1.2,
|
||||||
),
|
),
|
||||||
itemCount: state.allFiles.length,
|
itemCount: state.customerFiles.length,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
_FileCard(file: state.allFiles[index], state: state),
|
_FileCard(file: state.customerFiles[index], state: state),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,14 +268,14 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
|
|
||||||
class _FileCard extends StatelessWidget {
|
class _FileCard extends StatelessWidget {
|
||||||
final AttachmentModel file;
|
final AttachmentModel file;
|
||||||
final AttachmentsState state;
|
final CustomerFilesState state;
|
||||||
const _FileCard({required this.file, required this.state});
|
const _FileCard({required this.file, required this.state});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.read<AttachmentsBloc>().add(
|
onTap: () => context.read<CustomerFilesBloc>().add(
|
||||||
ToggleAttachmentSelectionEvent(file),
|
ToggleCustomerFileSelectionEvent(file),
|
||||||
),
|
),
|
||||||
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
||||||
|
|
||||||
class OldSharedUploadScreen extends StatefulWidget {
|
class CustomerMobileUploadScreen extends StatefulWidget {
|
||||||
final String title;
|
final String customerId;
|
||||||
final String companyId;
|
final String customerName;
|
||||||
|
|
||||||
const OldSharedUploadScreen({
|
const CustomerMobileUploadScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.customerId,
|
||||||
required this.companyId,
|
required this.customerName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OldSharedUploadScreen> createState() => _OldSharedUploadScreenState();
|
State<CustomerMobileUploadScreen> createState() =>
|
||||||
|
_CustomerMobileUploadScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
class _CustomerMobileUploadScreenState
|
||||||
|
extends State<CustomerMobileUploadScreen> {
|
||||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||||
final List<PlatformFile> _stagedFiles = [];
|
final List<PlatformFile> _stagedFiles = [];
|
||||||
|
|
||||||
// 2. STATO DI CARICAMENTO GLOBALE
|
// 2. STATO DI CARICAMENTO GLOBALE
|
||||||
bool _isUploading = false;
|
bool _isUploading = false;
|
||||||
bool _isProcessingLocal = false;
|
|
||||||
|
|
||||||
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
||||||
bool _isImage(String path) {
|
bool _isImage(String path) {
|
||||||
@@ -36,25 +36,18 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
return BlocListener<CustomerFilesBloc, CustomerFilesState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||||
if (state.status == AttachmentsStatus.success && _isUploading) {
|
if (state.status == CustomerFilesStatus.success && _isUploading) {
|
||||||
// CONTROLLO MAGICO: C'è una pagina dietro di noi?
|
|
||||||
if (Navigator.of(context).canPop()) {
|
|
||||||
// Modalità "App Nativa": siamo entrati dal tasto "Aggiungi"
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text("File caricati con successo! ✅")),
|
const SnackBar(
|
||||||
|
content: Text("Tutti i file caricati con successo! ✅"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else {
|
|
||||||
// Modalità "Web/QR Code": Navighiamo alla pagina di successo!
|
|
||||||
// Assicurati di aver importato go_router in questo file
|
|
||||||
|
|
||||||
context.go('/upload-success');
|
|
||||||
}
|
}
|
||||||
}
|
if (state.status == CustomerFilesStatus.failure) {
|
||||||
if (state.status == AttachmentsStatus.failure) {
|
|
||||||
setState(() => _isUploading = false);
|
setState(() => _isUploading = false);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
@@ -63,8 +56,8 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text("Upload: ${widget.title}"),
|
title: Text("Upload: ${widget.customerName}"),
|
||||||
// Togliamo la freccia indietro se stiamo caricando per evitare macelli
|
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
|
||||||
automaticallyImplyLeading: !_isUploading,
|
automaticallyImplyLeading: !_isUploading,
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@@ -117,7 +110,8 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3, // 3 colonne stile galleria
|
crossAxisCount:
|
||||||
|
3, // 3 colonne come la galleria dell'iPhone
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
),
|
),
|
||||||
@@ -143,17 +137,10 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: isImg
|
child: isImg
|
||||||
? (file.bytes != null
|
? Image.file(
|
||||||
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
|
|
||||||
? Image.memory(
|
|
||||||
file.bytes!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
// Altrimenti andiamo di file fisico
|
|
||||||
: Image.file(
|
|
||||||
File(file.path!),
|
File(file.path!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
))
|
)
|
||||||
: const Column(
|
: const Column(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment:
|
||||||
MainAxisAlignment.center,
|
MainAxisAlignment.center,
|
||||||
@@ -241,11 +228,11 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- OVERLAY DI CARICAMENTO ---
|
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
||||||
if (_isUploading || _isProcessingLocal)
|
if (_isUploading)
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
child: Center(
|
child: const Center(
|
||||||
child: Card(
|
child: Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(24.0),
|
padding: EdgeInsets.all(24.0),
|
||||||
@@ -255,9 +242,7 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_isUploading
|
"Caricamento in corso...",
|
||||||
? "Invio in corso..."
|
|
||||||
: "Elaborazione foto...",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -274,38 +259,30 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
|
|
||||||
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
||||||
Future<void> _handleCamera() async {
|
Future<void> _handleCamera() async {
|
||||||
setState(() => _isProcessingLocal = true);
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
try {
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final photo = await picker.pickImage(source: ImageSource.camera);
|
final photo = await picker.pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
final photoBytes = await photo.readAsBytes();
|
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
||||||
final photoSize = await photo.length();
|
final photoSize = await photo.length();
|
||||||
|
|
||||||
final platformFile = PlatformFile(
|
final platformFile = PlatformFile(
|
||||||
name: photo.name,
|
name: photo.name,
|
||||||
size: photoSize,
|
size: photoSize,
|
||||||
path: photo.path,
|
path: photo.path,
|
||||||
bytes: photoBytes,
|
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_stagedFiles.add(platformFile);
|
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setState(() => _isProcessingLocal = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleFilePicker() async {
|
Future<void> _handleFilePicker() async {
|
||||||
final result = await FilePicker.pickFiles(
|
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
|
||||||
allowMultiple: true,
|
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||||
withData: true,
|
|
||||||
);
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_stagedFiles.addAll(result.files);
|
_stagedFiles.addAll(result.files);
|
||||||
@@ -317,12 +294,11 @@ class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
|||||||
void _submitAllFiles() {
|
void _submitAllFiles() {
|
||||||
setState(() => _isUploading = true);
|
setState(() => _isUploading = true);
|
||||||
|
|
||||||
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
|
// Diciamo al BLoC di caricare tutti i file.
|
||||||
context.read<AttachmentsBloc>().add(
|
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
||||||
UploadAttachmentsEvent(
|
final bloc = context.read<CustomerFilesBloc>();
|
||||||
pickedFiles: _stagedFiles,
|
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
|
||||||
companyId: widget.companyId,
|
|
||||||
),
|
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/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/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_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:flux/features/customers/ui/customer_form.dart';
|
||||||
|
import 'package:flux/temp/migration_tools.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class CustomersContent extends StatefulWidget {
|
class CustomersContent extends StatefulWidget {
|
||||||
@@ -85,6 +86,42 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
//TODO cancella quando import finito
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
// 1. Mostra un loading (opzionale ma utile)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Caricamento JSON in corso...')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Legge tutto il file come stringa
|
||||||
|
final String jsonString = await rootBundle.loadString(
|
||||||
|
'assets/schedeRiparazione-1778021345.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Lancia lo script (sostituisci l'UUID con l'ID della tua azienda su Supabase)
|
||||||
|
await TicketMigrationScript().runMigration(jsonString);
|
||||||
|
|
||||||
|
// 4. Successo!
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Migrazione Completata! Guarda i log.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Errore: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('migra clienti'),
|
||||||
|
),
|
||||||
|
|
||||||
// LISTA CLIENTI
|
// LISTA CLIENTI
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||||
@@ -110,9 +147,8 @@ class _CustomersContentState extends State<CustomersContent> {
|
|||||||
final customer = state.customers[index];
|
final customer = state.customers[index];
|
||||||
return _CustomerTile(
|
return _CustomerTile(
|
||||||
customer: customer,
|
customer: customer,
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.push(
|
||||||
Routes.customerForm,
|
'/customer/${customer.id}',
|
||||||
pathParameters: {'id': customer.id!},
|
|
||||||
extra: customer,
|
extra: customer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +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/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/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/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
|
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
|
||||||
@@ -48,8 +47,6 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
|
||||||
onTap: () => context.pushNamed(Routes.operations),
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -72,6 +69,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => context.push('/operations'),
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.homeLatestOperations,
|
context.l10n.homeLatestOperations,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -83,6 +82,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -95,17 +95,12 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
LatestStoreOperationsState
|
LatestStoreOperationsState
|
||||||
>(
|
>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status ==
|
if (state.status == LatestStoreOperationsStatus.loading ||
|
||||||
LatestStoreOperationsStatus.loading ||
|
state.status == LatestStoreOperationsStatus.initial) {
|
||||||
state.status ==
|
return const Center(child: CircularProgressIndicator());
|
||||||
LatestStoreOperationsStatus.initial) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status ==
|
if (state.status == LatestStoreOperationsStatus.failure) {
|
||||||
LatestStoreOperationsStatus.failure) {
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Errore di caricamento",
|
"Errore di caricamento",
|
||||||
@@ -135,9 +130,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final operation = state.operations[index];
|
final operation = state.operations[index];
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.push(
|
||||||
Routes.operationForm,
|
'/operation-form',
|
||||||
pathParameters: {'id': operation.id!},
|
|
||||||
extra: operation,
|
extra: operation,
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -151,7 +145,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 5,
|
||||||
child: Text(
|
child: Text(
|
||||||
operation.customer?.name ??
|
operation.customerDisplayName ??
|
||||||
'Cliente sconosciuto',
|
'Cliente sconosciuto',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -190,7 +184,6 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
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/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/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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
@@ -80,14 +77,12 @@ class HomeScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
LatestStoreOperationsCard(),
|
LatestStoreOperationsCard(),
|
||||||
|
|
||||||
_buildDashboardWidget(
|
_buildDashboardWidget(
|
||||||
title: context.l10n.homeLatestOperationTickets,
|
title: context.l10n.homeLatestOperationTickets,
|
||||||
icon: Icons.support_agent_outlined,
|
icon: Icons.support_agent_outlined,
|
||||||
color: Colors.purple,
|
color: Colors.purple,
|
||||||
context: context,
|
context: context,
|
||||||
onTap: () => context.pushNamed(
|
|
||||||
Routes.tickets,
|
|
||||||
), // <-- Aggiunto!
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -188,14 +183,9 @@ 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: () async {
|
onTap: () {
|
||||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
|
||||||
if (createdBy == null || !context.mounted) return;
|
context.push('/operation-form');
|
||||||
context.pushNamed(
|
|
||||||
Routes.operationForm,
|
|
||||||
pathParameters: {'id': 'new'},
|
|
||||||
extra: (createdBy: createdBy, operation: null),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -203,14 +193,9 @@ 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: () async {
|
onTap: () {
|
||||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
// TODO: Quando avrai la rotta per la nuova assistenza
|
||||||
if (createdBy == null || !context.mounted) return;
|
// context.push('/assistance-form');
|
||||||
context.pushNamed(
|
|
||||||
Routes.ticketForm,
|
|
||||||
pathParameters: {'id': 'new'},
|
|
||||||
extra: (createdBy: createdBy, ticket: null),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -241,19 +226,15 @@ class HomeScreen extends StatelessWidget {
|
|||||||
required String title,
|
required String title,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required Color color,
|
required Color color,
|
||||||
VoidCallback? onTap,
|
|
||||||
}) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -309,7 +290,6 @@ class HomeScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +369,6 @@ 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,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
// Mantieni i tuoi import per il tema se usi le estensioni (es. context.accent)
|
// Mantieni i tuoi import per il tema se usi le estensioni (es. context.accent)
|
||||||
// import 'package:flux/core/theme/theme.dart';
|
// import 'package:flux/core/theme/theme.dart';
|
||||||
@@ -66,7 +65,7 @@ class MasterDataHubScreen extends StatelessWidget {
|
|||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
// Usiamo .push() perché avevamo detto che i clienti
|
// Usiamo .push() perché avevamo detto che i clienti
|
||||||
// stanno FUORI dalla Shell (niente BottomBar)
|
// stanno FUORI dalla Shell (niente BottomBar)
|
||||||
onTap: () => context.pushNamed(Routes.customers),
|
onTap: () => context.push('/customers'),
|
||||||
),
|
),
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
context,
|
context,
|
||||||
|
|||||||
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
|
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
part 'operation_files_events.dart';
|
||||||
|
part 'operation_files_state.dart';
|
||||||
|
|
||||||
|
class OperationFilesBloc
|
||||||
|
extends Bloc<OperationFilesEvent, OperationFilesState> {
|
||||||
|
final _repository = GetIt.I.get<OperationsRepository>();
|
||||||
|
final String? operationId;
|
||||||
|
|
||||||
|
OperationFilesBloc({this.operationId})
|
||||||
|
: super(
|
||||||
|
OperationFilesState(
|
||||||
|
status: OperationFilesStatus.initial,
|
||||||
|
operationId: operationId,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
on<OperationsavedEvent>(_onOperationsaved);
|
||||||
|
on<LoadOperationFilesEvent>(_onLoadOperationFiles);
|
||||||
|
on<AddOperationFilesEvent>(_onAddOperationFiles);
|
||||||
|
on<UploadOperationFilesEvent>(_onUploadOperationFiles);
|
||||||
|
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
|
||||||
|
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
|
||||||
|
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
|
||||||
|
on<RenameOperationFileEvent>(_onRenameOperationFile);
|
||||||
|
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
|
||||||
|
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
|
||||||
|
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
|
||||||
|
|
||||||
|
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
|
||||||
|
if (operationId != null) {
|
||||||
|
add(LoadOperationFilesEvent(operationId: operationId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onOperationsaved(
|
||||||
|
OperationsavedEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
// 1. Aggiorniamo l'ID e mettiamo in loading
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
operationId: event.operationId,
|
||||||
|
status: OperationFilesStatus.uploading,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. RECUPERO E UPLOAD DEI FILE "PARCHEGGIATI" (Pratica Nuova)
|
||||||
|
if (state.localFiles.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final List<Future<void>> uploadTasks = [];
|
||||||
|
|
||||||
|
for (var file in state.localFiles) {
|
||||||
|
// Ricreiamo il PlatformFile dal nostro AttachmentModel
|
||||||
|
// così il repository lo accetta senza fare storie!
|
||||||
|
final fakePlatformFile = PlatformFile(
|
||||||
|
name: '${file.name}.${file.extension}',
|
||||||
|
size: file.fileSize,
|
||||||
|
bytes: file.localBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
uploadTasks.add(
|
||||||
|
_repository.uploadAndRegisterOperationFile(
|
||||||
|
operationId: event.operationId, // L'ID APPENA NATO!
|
||||||
|
pickedFile: fakePlatformFile,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lanciamo tutti gli upload in parallelo
|
||||||
|
await Future.wait(uploadTasks);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: "Errore upload post-salvataggio: $e",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return; // Ci fermiamo qui se esplode qualcosa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. FINE DEI GIOCHI! Svuotiamo i locali, passiamo a success e accendiamo lo Stream
|
||||||
|
emit(state.copyWith(localFiles: [], status: OperationFilesStatus.success));
|
||||||
|
|
||||||
|
add(LoadOperationFilesEvent(operationId: event.operationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onLoadOperationFiles(
|
||||||
|
LoadOperationFilesEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
final currentId = event.operationId ?? state.operationId;
|
||||||
|
|
||||||
|
if (currentId != null) {
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||||
|
|
||||||
|
await emit.forEach(
|
||||||
|
_repository.getOperationFilesStream(currentId),
|
||||||
|
onData: (List<AttachmentModel> data) => state.copyWith(
|
||||||
|
status: OperationFilesStatus.success,
|
||||||
|
remoteFiles: data,
|
||||||
|
),
|
||||||
|
onError: (error, stackTrace) => state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: error.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddOperationFiles(
|
||||||
|
AddOperationFilesEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
final currentId = state.operationId;
|
||||||
|
|
||||||
|
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
|
||||||
|
if (currentId == null) {
|
||||||
|
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
final newLocalFiles = event.files.map((file) {
|
||||||
|
return AttachmentModel(
|
||||||
|
id: null,
|
||||||
|
companyId: companyId,
|
||||||
|
operationId: '', // Sarà riempito al salvataggio
|
||||||
|
name: file.name.fileNameWithoutExtension(),
|
||||||
|
extension: file.name.fileExtension(),
|
||||||
|
storagePath: '',
|
||||||
|
fileSize: file.size,
|
||||||
|
localBytes: file.bytes,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
localFiles: [...state.localFiles, ...newLocalFiles],
|
||||||
|
status: OperationFilesStatus.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato)
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.uploading));
|
||||||
|
try {
|
||||||
|
final List<Future<void>> uploadTasks = [];
|
||||||
|
for (var file in event.files) {
|
||||||
|
uploadTasks.add(
|
||||||
|
_repository.uploadAndRegisterOperationFile(
|
||||||
|
operationId: currentId,
|
||||||
|
pickedFile: file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Future.wait(uploadTasks);
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onUploadOperationFiles(
|
||||||
|
UploadOperationFilesEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
|
||||||
|
(event.photos == null || event.photos!.isEmpty)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.operationId == null) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.uploading));
|
||||||
|
try {
|
||||||
|
final List<Future<void>> uploadTasks = [];
|
||||||
|
|
||||||
|
// 1. Gestione Documenti normali (PlatformFile)
|
||||||
|
if (event.pickedFiles != null) {
|
||||||
|
for (var file in event.pickedFiles!) {
|
||||||
|
uploadTasks.add(
|
||||||
|
_repository.uploadAndRegisterOperationFile(
|
||||||
|
operationId: state.operationId!,
|
||||||
|
pickedFile: file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Gestione Foto Fotocamera (XFile)
|
||||||
|
if (event.photos != null) {
|
||||||
|
for (var photo in event.photos!) {
|
||||||
|
// Leggiamo i byte asincronamente
|
||||||
|
final bytes = await photo.readAsBytes();
|
||||||
|
final fileSize = await photo.length();
|
||||||
|
|
||||||
|
// Lo travestiamo da PlatformFile per passarlo al Repository!
|
||||||
|
final fakePlatformFile = PlatformFile(
|
||||||
|
name: photo.name,
|
||||||
|
size: fileSize,
|
||||||
|
bytes: bytes,
|
||||||
|
path: photo.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
uploadTasks.add(
|
||||||
|
_repository.uploadAndRegisterOperationFile(
|
||||||
|
operationId: state.operationId!,
|
||||||
|
pickedFile: fakePlatformFile,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esecuzione parallela di tutti i documenti e foto
|
||||||
|
await Future.wait(uploadTasks);
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onDeleteOperationFiles(
|
||||||
|
DeleteOperationFilesEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||||
|
try {
|
||||||
|
await _repository.deleteOperationFiles(state.selectedFiles);
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onToggleOperationFileSelection(
|
||||||
|
ToggleOperationFileSelectionEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) {
|
||||||
|
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
|
||||||
|
if (selectedFiles.contains(event.file)) {
|
||||||
|
selectedFiles.remove(event.file);
|
||||||
|
} else {
|
||||||
|
selectedFiles.add(event.file);
|
||||||
|
}
|
||||||
|
emit(state.copyWith(selectedFiles: selectedFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectAllOperationFiles(
|
||||||
|
SelectAllOperationFilesEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) {
|
||||||
|
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
|
||||||
|
emit(state.copyWith(selectedFiles: state.allFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onClearOperationFileSelection(
|
||||||
|
ClearOperationFileSelectionEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) {
|
||||||
|
// Svuotiamo brutalmente la lista
|
||||||
|
emit(state.copyWith(selectedFiles: []));
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onLinkFilesToCustomer(
|
||||||
|
LinkFilesToCustomerEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
if (state.selectedFiles.isEmpty) return;
|
||||||
|
|
||||||
|
// BIVIO 1: PRATICA NUOVA (Modalità Locale)
|
||||||
|
if (state.operationId == null) {
|
||||||
|
// Mappiamo i file locali: se sono tra quelli selezionati, iniettiamo il customerId
|
||||||
|
final updatedLocalFiles = state.localFiles.map((file) {
|
||||||
|
if (state.selectedFiles.contains(file)) {
|
||||||
|
return file.copyWith(customerId: event.customerId);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
localFiles: updatedLocalFiles,
|
||||||
|
selectedFiles: [], // Svuotiamo la selezione dopo averli associati
|
||||||
|
status: OperationFilesStatus.success, // o un toast di feedback
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIVIO 2: PRATICA ESISTENTE (Modalità Remota su DB)
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||||
|
try {
|
||||||
|
final List<Future<void>> linkTasks = [];
|
||||||
|
|
||||||
|
for (var file in state.selectedFiles) {
|
||||||
|
linkTasks.add(
|
||||||
|
_repository.copyFileToCustomer(
|
||||||
|
file: file,
|
||||||
|
customerId: event.customerId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(linkTasks);
|
||||||
|
|
||||||
|
// Svuotiamo la selezione.
|
||||||
|
// NON serve aggiornare la lista a mano, perché il DB si aggiorna
|
||||||
|
// e lo Stream di Supabase spingerà automaticamente in UI i file aggiornati!
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: "Errore associazione: $e",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onRenameOperationFile(
|
||||||
|
RenameOperationFileEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) async {
|
||||||
|
// BIVIO 1: File Locale (Bozza)
|
||||||
|
if (event.file.localBytes != null) {
|
||||||
|
final updatedLocalFiles = state.localFiles.map((f) {
|
||||||
|
if (f == event.file) {
|
||||||
|
return f.copyWith(name: event.newName);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}).toList();
|
||||||
|
emit(state.copyWith(localFiles: updatedLocalFiles));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIVIO 2: File Remoto (Salvato su DB)
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||||
|
try {
|
||||||
|
await _repository.renameAttachment(event.file.id!, event.newName);
|
||||||
|
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationFilesStatus.failure,
|
||||||
|
error: "Errore rinomina: $e",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<void> _onDeleteSpecificOperationFiles(
|
||||||
|
DeleteSpecificOperationFileEvent event,
|
||||||
|
Emitter<OperationFilesState> emit,
|
||||||
|
) {
|
||||||
|
if (event.file.localBytes != null) {
|
||||||
|
final updatedLocalFiles = state.localFiles
|
||||||
|
.where((f) => f != event.file)
|
||||||
|
.toList();
|
||||||
|
emit(state.copyWith(localFiles: updatedLocalFiles));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
part of 'operation_files_bloc.dart';
|
||||||
|
|
||||||
|
abstract class OperationFilesEvent extends Equatable {
|
||||||
|
const OperationFilesEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsavedEvent extends OperationFilesEvent {
|
||||||
|
final String operationId;
|
||||||
|
const OperationsavedEvent(this.operationId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [operationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadOperationFilesEvent extends OperationFilesEvent {
|
||||||
|
final String? operationId;
|
||||||
|
final AttachmentModel? operation;
|
||||||
|
const LoadOperationFilesEvent({this.operationId, this.operation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [operationId, operation];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddOperationFilesEvent extends OperationFilesEvent {
|
||||||
|
final List<PlatformFile> files;
|
||||||
|
const AddOperationFilesEvent(this.files);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [files];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadOperationFilesEvent extends OperationFilesEvent {
|
||||||
|
final List<PlatformFile>? pickedFiles;
|
||||||
|
final List<XFile>? photos;
|
||||||
|
const UploadOperationFilesEvent({this.pickedFiles, this.photos});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [pickedFiles, photos];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkFilesToCustomerEvent extends OperationFilesEvent {
|
||||||
|
final String customerId;
|
||||||
|
|
||||||
|
const LinkFilesToCustomerEvent({required this.customerId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [customerId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteOperationFilesEvent extends OperationFilesEvent {}
|
||||||
|
|
||||||
|
class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
|
||||||
|
final AttachmentModel file;
|
||||||
|
const ToggleOperationFileSelectionEvent(this.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RenameOperationFileEvent extends OperationFilesEvent {
|
||||||
|
final AttachmentModel file;
|
||||||
|
final String newName;
|
||||||
|
|
||||||
|
const RenameOperationFileEvent(this.file, this.newName);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [file, newName];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
|
||||||
|
final AttachmentModel file;
|
||||||
|
|
||||||
|
const DeleteSpecificOperationFileEvent(this.file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [file];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
|
||||||
|
|
||||||
|
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}
|
||||||
@@ -1,29 +1,10 @@
|
|||||||
part of 'attachments_bloc.dart';
|
part of 'operation_files_bloc.dart';
|
||||||
|
|
||||||
enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
|
enum OperationFilesStatus { initial, loading, uploading, success, failure }
|
||||||
|
|
||||||
enum AttachmentParentType {
|
class OperationFilesState extends Equatable {
|
||||||
operation('operation_id'),
|
const OperationFilesState({
|
||||||
ticket('ticket_id'),
|
this.operationId,
|
||||||
customer('customer_id');
|
|
||||||
|
|
||||||
final String dbColumn;
|
|
||||||
const AttachmentParentType(this.dbColumn);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AttachmentsState extends Equatable {
|
|
||||||
final String? parentId;
|
|
||||||
final AttachmentParentType parentType;
|
|
||||||
final AttachmentsStatus status;
|
|
||||||
final String? error;
|
|
||||||
|
|
||||||
final List<AttachmentModel> localFiles;
|
|
||||||
final List<AttachmentModel> remoteFiles;
|
|
||||||
final List<AttachmentModel> selectedFiles;
|
|
||||||
|
|
||||||
const AttachmentsState({
|
|
||||||
this.parentId,
|
|
||||||
required this.parentType,
|
|
||||||
required this.status,
|
required this.status,
|
||||||
this.error,
|
this.error,
|
||||||
this.localFiles = const [],
|
this.localFiles = const [],
|
||||||
@@ -31,10 +12,17 @@ class AttachmentsState extends Equatable {
|
|||||||
this.selectedFiles = const [],
|
this.selectedFiles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String? operationId;
|
||||||
|
final OperationFilesStatus status;
|
||||||
|
final String? error;
|
||||||
|
final List<AttachmentModel> localFiles;
|
||||||
|
final List<AttachmentModel> remoteFiles;
|
||||||
|
|
||||||
|
final List<AttachmentModel> selectedFiles;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
parentId,
|
operationId,
|
||||||
parentType,
|
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
localFiles,
|
localFiles,
|
||||||
@@ -44,18 +32,16 @@ class AttachmentsState extends Equatable {
|
|||||||
|
|
||||||
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
||||||
|
|
||||||
AttachmentsState copyWith({
|
OperationFilesState copyWith({
|
||||||
String? parentId,
|
String? operationId,
|
||||||
AttachmentParentType? parentType,
|
OperationFilesStatus? status,
|
||||||
AttachmentsStatus? status,
|
|
||||||
String? error,
|
String? error,
|
||||||
List<AttachmentModel>? localFiles,
|
List<AttachmentModel>? localFiles,
|
||||||
List<AttachmentModel>? remoteFiles,
|
List<AttachmentModel>? remoteFiles,
|
||||||
List<AttachmentModel>? selectedFiles,
|
List<AttachmentModel>? selectedFiles,
|
||||||
}) {
|
}) {
|
||||||
return AttachmentsState(
|
return OperationFilesState(
|
||||||
parentId: parentId ?? this.parentId,
|
operationId: operationId ?? this.operationId,
|
||||||
parentType: parentType ?? this.parentType,
|
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
error: error,
|
error: error,
|
||||||
localFiles: localFiles ?? this.localFiles,
|
localFiles: localFiles ?? this.localFiles,
|
||||||
@@ -1,280 +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/customers/models/customer_model.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|
||||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
part 'operation_form_state.dart';
|
|
||||||
|
|
||||||
class OperationFormCubit extends Cubit<OperationFormState> {
|
|
||||||
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
|
||||||
final Uuid _uuid = const Uuid();
|
|
||||||
|
|
||||||
OperationFormCubit({
|
|
||||||
StaffMemberModel? createdBy,
|
|
||||||
OperationModel? existingOperation,
|
|
||||||
}) : super(
|
|
||||||
OperationFormState(
|
|
||||||
operation:
|
|
||||||
existingOperation ??
|
|
||||||
OperationModel.empty().copyWith(
|
|
||||||
staffId: createdBy?.id,
|
|
||||||
staffDisplayName: createdBy?.name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> initForm({
|
|
||||||
OperationModel? existingOperation,
|
|
||||||
String? operationId,
|
|
||||||
}) async {
|
|
||||||
emit(state.copyWith(status: OperationFormStatus.loading));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (existingOperation != null) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
operation: existingOperation,
|
|
||||||
status: OperationFormStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (operationId != null) {
|
|
||||||
emit(state.copyWith(status: OperationFormStatus.loading));
|
|
||||||
try {
|
|
||||||
final operation = await _repository.fetchOperationById(operationId);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
operation: operation,
|
|
||||||
status: OperationFormStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on Exception catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
|
|
||||||
final currentStore = _sessionCubit.state.currentStore;
|
|
||||||
final companyId = _sessionCubit.state.company?.id ?? '';
|
|
||||||
final newOperation = state.operation.copyWith(
|
|
||||||
companyId: companyId,
|
|
||||||
storeId: currentStore?.id,
|
|
||||||
status: OperationStatus.success,
|
|
||||||
reference: '',
|
|
||||||
batchUuid: _uuid.v4(),
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
operation: newOperation,
|
|
||||||
status: OperationFormStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.failure,
|
|
||||||
errorMessage: "Errore inizializzazione form: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LOGICA BATCH ---
|
|
||||||
|
|
||||||
void _prepareNextOperationInBatch() {
|
|
||||||
final current = state.operation;
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.ready, // Torna ready per il nuovo form
|
|
||||||
operation: OperationModel(
|
|
||||||
companyId: current.companyId,
|
|
||||||
storeId: current.storeId,
|
|
||||||
storeDisplayName: current.storeDisplayName,
|
|
||||||
batchUuid: current.batchUuid, // MANTIENE IL COLLEGAMENTO
|
|
||||||
customerId: current.customerId, // MANTIENE IL CLIENTE
|
|
||||||
customer: current.customer,
|
|
||||||
status: OperationStatus.draft,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SALVATAGGIO ---
|
|
||||||
|
|
||||||
Future<void> saveOperation({
|
|
||||||
required OperationStatus targetStatus,
|
|
||||||
required bool keepAdding,
|
|
||||||
}) async {
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: OperationFormStatus.saving, errorMessage: null),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final operationToSave = state.operation.copyWith(status: targetStatus);
|
|
||||||
final savedOperation = await _repository.saveFullOperation(
|
|
||||||
operation: operationToSave,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (keepAdding) {
|
|
||||||
// Salviamo nella "memoria" del batch le pratiche create finora
|
|
||||||
final updatedBatchList = List<OperationModel>.from(
|
|
||||||
state.savedBatchOperations,
|
|
||||||
)..add(savedOperation);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.successAndAddAnother,
|
|
||||||
savedBatchOperations: updatedBatchList,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pulisce i campi per la prossima operazione
|
|
||||||
_prepareNextOperationInBatch();
|
|
||||||
} else {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.success,
|
|
||||||
operation: savedOperation, // Aggiorniamo con l'ID restituito dal DB
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> saveOperationDraft() async {
|
|
||||||
try {
|
|
||||||
final operationToSave = state.operation;
|
|
||||||
if (operationToSave.customerId == null ||
|
|
||||||
operationToSave.customerId!.isEmpty) {
|
|
||||||
throw Exception('Seleziona un cliente prima di poter usare il QR');
|
|
||||||
}
|
|
||||||
final savedOperation = await _repository.saveFullOperation(
|
|
||||||
operation: operationToSave,
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
operation: savedOperation,
|
|
||||||
status: OperationFormStatus.ready,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return savedOperation.id;
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationFormStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GESTIONE DEI CAMPI IN TEMPO REALE ---
|
|
||||||
|
|
||||||
void updateFields({
|
|
||||||
CustomerModel? customer,
|
|
||||||
String? reference,
|
|
||||||
String? note,
|
|
||||||
String? type,
|
|
||||||
String? providerId,
|
|
||||||
String? providerDisplayName,
|
|
||||||
String? subtype,
|
|
||||||
String? description,
|
|
||||||
DateTime? expirationDate,
|
|
||||||
int? quantity,
|
|
||||||
String? modelId,
|
|
||||||
String? modelDisplayName,
|
|
||||||
String? staffId,
|
|
||||||
String? staffDisplayName,
|
|
||||||
OperationStatus? status,
|
|
||||||
|
|
||||||
bool clearProvider = false,
|
|
||||||
bool clearType = false,
|
|
||||||
bool clearSubtype = false,
|
|
||||||
bool clearDescription = false,
|
|
||||||
bool clearExpiration = false,
|
|
||||||
bool clearQuantity = false,
|
|
||||||
bool clearModel = false,
|
|
||||||
}) {
|
|
||||||
final current = state.operation;
|
|
||||||
|
|
||||||
int? newQuantity;
|
|
||||||
if (clearQuantity) newQuantity = 1;
|
|
||||||
if (quantity != null && quantity <= 0) newQuantity = 0;
|
|
||||||
if (quantity != null && quantity > 0) newQuantity = quantity;
|
|
||||||
|
|
||||||
final updated = current.copyWith(
|
|
||||||
customer: customer ?? current.customer,
|
|
||||||
customerId: customer?.id ?? current.customerId,
|
|
||||||
reference: reference ?? current.reference,
|
|
||||||
note: note ?? current.note,
|
|
||||||
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
|
||||||
providerDisplayName: clearProvider
|
|
||||||
? null
|
|
||||||
: (providerDisplayName ?? current.providerDisplayName),
|
|
||||||
quantity: newQuantity ?? current.quantity,
|
|
||||||
type: clearType ? null : (type ?? current.type),
|
|
||||||
description: clearDescription
|
|
||||||
? null
|
|
||||||
: (description ?? current.description),
|
|
||||||
subtype: clearSubtype ? null : (subtype ?? current.subtype),
|
|
||||||
expirationDate: clearExpiration
|
|
||||||
? null
|
|
||||||
: (expirationDate ?? current.expirationDate),
|
|
||||||
modelId: clearModel ? null : (modelId ?? current.modelId),
|
|
||||||
modelDisplayName: clearModel
|
|
||||||
? null
|
|
||||||
: (modelDisplayName ?? current.modelDisplayName),
|
|
||||||
staffId: staffId ?? current.staffId,
|
|
||||||
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
|
|
||||||
status: status ?? current.status,
|
|
||||||
);
|
|
||||||
|
|
||||||
emit(state.copyWith(operation: updated));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UTILS ---
|
|
||||||
|
|
||||||
void setTypeWithSmartDefault(String type) {
|
|
||||||
DateTime? defaultDate;
|
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
if (type == 'Energy') {
|
|
||||||
defaultDate = DateTime(now.year, now.month + 24, now.day);
|
|
||||||
}
|
|
||||||
if (type == 'Fin') {
|
|
||||||
defaultDate = DateTime(now.year, now.month + 30, now.day);
|
|
||||||
}
|
|
||||||
if (type == 'Entertainment') {
|
|
||||||
defaultDate = DateTime(now.year, now.month + 12, now.day);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFields(
|
|
||||||
type: type,
|
|
||||||
expirationDate: defaultDate,
|
|
||||||
clearProvider: true,
|
|
||||||
clearSubtype: true,
|
|
||||||
clearModel: true,
|
|
||||||
clearQuantity: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
part of 'operation_form_cubit.dart';
|
|
||||||
|
|
||||||
enum OperationFormStatus {
|
|
||||||
initial,
|
|
||||||
loading,
|
|
||||||
ready,
|
|
||||||
saving,
|
|
||||||
success,
|
|
||||||
successAndAddAnother, // Nuovo stato in stile Ticket!
|
|
||||||
failure,
|
|
||||||
}
|
|
||||||
|
|
||||||
class OperationFormState extends Equatable {
|
|
||||||
final OperationFormStatus status;
|
|
||||||
final OperationModel operation;
|
|
||||||
final String? errorMessage;
|
|
||||||
// Teniamo traccia delle operazioni salvate in questa sessione (per UI riepilogo)
|
|
||||||
final List<OperationModel> savedBatchOperations;
|
|
||||||
|
|
||||||
const OperationFormState({
|
|
||||||
this.status = OperationFormStatus.initial,
|
|
||||||
required this.operation,
|
|
||||||
this.errorMessage,
|
|
||||||
this.savedBatchOperations = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
OperationFormState copyWith({
|
|
||||||
OperationFormStatus? status,
|
|
||||||
OperationModel? operation,
|
|
||||||
String? errorMessage,
|
|
||||||
List<OperationModel>? savedBatchOperations,
|
|
||||||
}) {
|
|
||||||
return OperationFormState(
|
|
||||||
status: status ?? this.status,
|
|
||||||
operation: operation ?? this.operation,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
savedBatchOperations: savedBatchOperations ?? this.savedBatchOperations,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
status,
|
|
||||||
operation,
|
|
||||||
errorMessage,
|
|
||||||
savedBatchOperations,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import 'package:equatable/equatable.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/features/operations/data/operations_repository.dart';
|
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
|
|
||||||
part 'operation_list_state.dart';
|
|
||||||
|
|
||||||
class OperationListCubit extends Cubit<OperationListState> {
|
|
||||||
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
|
||||||
|
|
||||||
OperationListCubit() : super(const OperationListState()) {
|
|
||||||
loadOperations(refresh: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadOperations({bool refresh = false}) async {
|
|
||||||
if (state.status == OperationListStatus.loading) return;
|
|
||||||
if (!refresh && state.hasReachedMax) return;
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationListStatus.loading,
|
|
||||||
errorMessage: null,
|
|
||||||
operations: refresh ? [] : state.operations,
|
|
||||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final currentOffset = refresh ? 0 : state.operations.length;
|
|
||||||
final companyId = _sessionCubit.state.company?.id;
|
|
||||||
|
|
||||||
if (companyId == null) {
|
|
||||||
throw Exception("Company ID non trovato nella sessione");
|
|
||||||
}
|
|
||||||
|
|
||||||
final newOperations = await _repository.fetchOperations(
|
|
||||||
companyId: companyId,
|
|
||||||
offset: currentOffset,
|
|
||||||
limit: 50,
|
|
||||||
searchTerm: state.query,
|
|
||||||
dateRange: state.dateRange,
|
|
||||||
);
|
|
||||||
|
|
||||||
final bool reachedMax = newOperations.length < 50;
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationListStatus.success,
|
|
||||||
operations: refresh
|
|
||||||
? newOperations
|
|
||||||
: [...state.operations, ...newOperations],
|
|
||||||
hasReachedMax: reachedMax,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationListStatus.failure,
|
|
||||||
errorMessage: "Errore nel caricamento operazioni: $e",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateFilters({String? query, DateTimeRange? range}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
query: query ?? state.query,
|
|
||||||
dateRange: range ?? state.dateRange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
loadOperations(refresh: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearFilters() {
|
|
||||||
emit(const OperationListState()); // Resetta tutto allo stato iniziale
|
|
||||||
loadOperations(refresh: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
part of 'operation_list_cubit.dart';
|
|
||||||
|
|
||||||
enum OperationListStatus { initial, loading, success, failure }
|
|
||||||
|
|
||||||
class OperationListState extends Equatable {
|
|
||||||
final OperationListStatus status;
|
|
||||||
final List<OperationModel> operations;
|
|
||||||
final bool hasReachedMax;
|
|
||||||
final String? errorMessage;
|
|
||||||
final String query;
|
|
||||||
final DateTimeRange? dateRange;
|
|
||||||
|
|
||||||
const OperationListState({
|
|
||||||
this.status = OperationListStatus.initial,
|
|
||||||
this.operations = const [],
|
|
||||||
this.hasReachedMax = false,
|
|
||||||
this.errorMessage,
|
|
||||||
this.query = '',
|
|
||||||
this.dateRange,
|
|
||||||
});
|
|
||||||
|
|
||||||
OperationListState copyWith({
|
|
||||||
OperationListStatus? status,
|
|
||||||
List<OperationModel>? operations,
|
|
||||||
bool? hasReachedMax,
|
|
||||||
String? errorMessage,
|
|
||||||
String? query,
|
|
||||||
DateTimeRange? dateRange,
|
|
||||||
}) {
|
|
||||||
return OperationListState(
|
|
||||||
status: status ?? this.status,
|
|
||||||
operations: operations ?? this.operations,
|
|
||||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
query: query ?? this.query,
|
|
||||||
dateRange: dateRange ?? this.dateRange,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
status,
|
|
||||||
operations,
|
|
||||||
hasReachedMax,
|
|
||||||
errorMessage,
|
|
||||||
query,
|
|
||||||
dateRange,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import 'package:equatable/equatable.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/features/operations/data/operations_repository.dart';
|
||||||
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
part 'operations_state.dart';
|
||||||
|
|
||||||
|
class OperationsCubit extends Cubit<OperationsState> {
|
||||||
|
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
||||||
|
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||||
|
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
|
||||||
|
|
||||||
|
OperationsCubit()
|
||||||
|
: super(const OperationsState(status: OperationsStatus.initial));
|
||||||
|
|
||||||
|
// --- CARICAMENTO E PAGINAZIONE ---
|
||||||
|
|
||||||
|
Future<void> loadOperations({bool refresh = false}) async {
|
||||||
|
if (state.status == OperationsStatus.loading) return;
|
||||||
|
if (!refresh && state.hasReachedMax) return;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationsStatus.loading,
|
||||||
|
errorMessage: null,
|
||||||
|
allOperations: refresh ? [] : state.allOperations,
|
||||||
|
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentOffset = refresh ? 0 : state.allOperations.length;
|
||||||
|
final companyId = _sessionCubit.state.company?.id;
|
||||||
|
|
||||||
|
if (companyId == null) {
|
||||||
|
throw Exception("Company ID non trovato nella sessione");
|
||||||
|
}
|
||||||
|
|
||||||
|
final newOperations = await _repository.fetchOperations(
|
||||||
|
companyId: companyId,
|
||||||
|
offset: currentOffset,
|
||||||
|
limit: 50,
|
||||||
|
searchTerm: state.query,
|
||||||
|
dateRange: state.dateRange,
|
||||||
|
);
|
||||||
|
|
||||||
|
final bool reachedMax = newOperations.length < 50;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationsStatus.ready,
|
||||||
|
allOperations: refresh
|
||||||
|
? newOperations
|
||||||
|
: [...state.allOperations, ...newOperations],
|
||||||
|
hasReachedMax: reachedMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationsStatus.failure,
|
||||||
|
errorMessage: "Errore nel caricamento operazioni: $e",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTIONE FILTRI ---
|
||||||
|
|
||||||
|
void updateFilters({String? query, DateTimeRange? range}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
query: query ?? state.query,
|
||||||
|
dateRange: range ?? state.dateRange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
loadOperations(refresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFilters() {
|
||||||
|
emit(state.copyWith(query: '', dateRange: null));
|
||||||
|
loadOperations(refresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initOperationForm({
|
||||||
|
OperationModel? existingOperation,
|
||||||
|
String? operationId,
|
||||||
|
String? staffId,
|
||||||
|
String? staffDisplayName,
|
||||||
|
}) async {
|
||||||
|
if (existingOperation != null) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
currentOperation: existingOperation,
|
||||||
|
status: OperationsStatus.ready,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (operationId != null) {
|
||||||
|
OperationModel? operationModel = state.allOperations.firstWhereOrNull(
|
||||||
|
(s) => s.id == operationId,
|
||||||
|
);
|
||||||
|
operationModel ??= await _repository.fetchOperationById(operationId);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
currentOperation: operationModel,
|
||||||
|
status: OperationsStatus.ready,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
currentOperation: OperationModel(
|
||||||
|
storeId: _sessionCubit.state.currentStore?.id ?? '',
|
||||||
|
reference: '',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
companyId: _sessionCubit.state.company!.id!,
|
||||||
|
status: OperationStatus.draft,
|
||||||
|
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
|
||||||
|
),
|
||||||
|
status: OperationsStatus.ready,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
|
||||||
|
/// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
|
||||||
|
void prepareNextOperationInBatch() {
|
||||||
|
if (state.currentOperation == null) return;
|
||||||
|
|
||||||
|
final current = state.currentOperation!;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationsStatus.ready,
|
||||||
|
currentOperation: OperationModel(
|
||||||
|
companyId: current.companyId,
|
||||||
|
storeId: current.storeId,
|
||||||
|
storeDisplayName: current.storeDisplayName,
|
||||||
|
batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
|
||||||
|
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
|
||||||
|
customerDisplayName: current.customerDisplayName,
|
||||||
|
status: OperationStatus.draft,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PERSISTENZA ---
|
||||||
|
|
||||||
|
Future<void> saveCurrentOperation({
|
||||||
|
required OperationStatus targetStatus,
|
||||||
|
bool shouldPop = true,
|
||||||
|
}) async {
|
||||||
|
if (state.currentOperation == null) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
|
||||||
|
try {
|
||||||
|
final operationToSave = state.currentOperation!.copyWith(
|
||||||
|
status: targetStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedOperation = await _repository.saveFullOperation(
|
||||||
|
operation: operationToSave,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
|
||||||
|
status: shouldPop
|
||||||
|
? OperationsStatus.saved
|
||||||
|
: OperationsStatus.savedNoPop,
|
||||||
|
currentOperation: shouldPop ? null : updatedOperation,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ricarica in background per la dashboard
|
||||||
|
loadOperations(refresh: true);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationsStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
|
||||||
|
|
||||||
|
/// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
|
||||||
|
List<OperationModel> getOperationsInCurrentBatch() {
|
||||||
|
if (state.currentOperation == null) return [];
|
||||||
|
final currentBatch = state.currentOperation!.batchUuid;
|
||||||
|
|
||||||
|
// Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
|
||||||
|
return state.allOperations
|
||||||
|
.where(
|
||||||
|
(op) =>
|
||||||
|
op.batchUuid == currentBatch &&
|
||||||
|
op.id != state.currentOperation!.id,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
|
||||||
|
void updateOperationFields({
|
||||||
|
String? customerId,
|
||||||
|
String? customerDisplayName,
|
||||||
|
String? type,
|
||||||
|
String? providerId,
|
||||||
|
String? providerDisplayName,
|
||||||
|
String? subtype,
|
||||||
|
String? description,
|
||||||
|
DateTime? expirationDate,
|
||||||
|
int? quantity,
|
||||||
|
String? modelId,
|
||||||
|
String? modelDisplayName,
|
||||||
|
String? staffId,
|
||||||
|
String? staffDisplayName,
|
||||||
|
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
|
||||||
|
bool clearProvider = false,
|
||||||
|
bool clearType = false,
|
||||||
|
bool clearSubtype = false,
|
||||||
|
bool clearDescription = false,
|
||||||
|
bool clearExpiration = false,
|
||||||
|
bool clearQuantity = false,
|
||||||
|
bool clearModel = false,
|
||||||
|
}) {
|
||||||
|
if (state.currentOperation == null) return;
|
||||||
|
|
||||||
|
final current = state.currentOperation!;
|
||||||
|
|
||||||
|
// Creiamo il modello aggiornato
|
||||||
|
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
|
||||||
|
int? newQuantity;
|
||||||
|
if (clearQuantity) {
|
||||||
|
newQuantity = 1;
|
||||||
|
}
|
||||||
|
if (quantity != null && quantity <= 0) {
|
||||||
|
newQuantity = 0;
|
||||||
|
}
|
||||||
|
if (quantity != null && quantity > 0) {
|
||||||
|
newQuantity = quantity;
|
||||||
|
}
|
||||||
|
final updated = current.copyWith(
|
||||||
|
customerId: customerId,
|
||||||
|
customerDisplayName: customerDisplayName,
|
||||||
|
|
||||||
|
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
|
||||||
|
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
||||||
|
providerDisplayName: clearProvider
|
||||||
|
? null
|
||||||
|
: (providerDisplayName ?? current.providerDisplayName),
|
||||||
|
quantity: newQuantity,
|
||||||
|
type: clearType ? null : (type ?? current.type),
|
||||||
|
description: clearDescription
|
||||||
|
? null
|
||||||
|
: (description ?? current.description),
|
||||||
|
subtype: clearSubtype ? null : (subtype ?? current.subtype),
|
||||||
|
expirationDate: clearExpiration
|
||||||
|
? null
|
||||||
|
: (expirationDate ?? current.expirationDate),
|
||||||
|
modelId: clearModel ? null : (modelId ?? current.modelId),
|
||||||
|
modelDisplayName: clearModel
|
||||||
|
? null
|
||||||
|
: (modelDisplayName ?? current.modelDisplayName),
|
||||||
|
staffId: staffId ?? current.staffId,
|
||||||
|
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(state.copyWith(currentOperation: updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metodo di utilità per calcolare la data X mesi da oggi
|
||||||
|
DateTime _calculateMonths(int months) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return DateTime(now.year, now.month + months, now.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quando l'utente seleziona un tipo, impostiamo il default
|
||||||
|
void setTypeWithSmartDefault(String type) {
|
||||||
|
DateTime? defaultDate;
|
||||||
|
|
||||||
|
if (type == 'Energy') defaultDate = _calculateMonths(24);
|
||||||
|
if (type == 'Fin') defaultDate = _calculateMonths(30);
|
||||||
|
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
|
||||||
|
|
||||||
|
updateOperationFields(
|
||||||
|
type: type,
|
||||||
|
expirationDate: defaultDate,
|
||||||
|
clearProvider: true,
|
||||||
|
clearSubtype: true,
|
||||||
|
clearModel: true,
|
||||||
|
clearQuantity: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/features/operations/blocs/operations_state.dart
Normal file
68
lib/features/operations/blocs/operations_state.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
part of 'operations_cubit.dart';
|
||||||
|
|
||||||
|
enum OperationsStatus {
|
||||||
|
initial,
|
||||||
|
loading,
|
||||||
|
ready,
|
||||||
|
saving,
|
||||||
|
saved,
|
||||||
|
savedNoPop,
|
||||||
|
success,
|
||||||
|
failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationsState extends Equatable {
|
||||||
|
final OperationsStatus status;
|
||||||
|
final List<OperationModel> allOperations;
|
||||||
|
final OperationModel? currentOperation; // La bozza che stiamo editando
|
||||||
|
final String? errorMessage;
|
||||||
|
final String query;
|
||||||
|
final DateTimeRange? dateRange;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
final bool isSavingDraft;
|
||||||
|
|
||||||
|
const OperationsState({
|
||||||
|
required this.status,
|
||||||
|
this.allOperations = const [],
|
||||||
|
this.currentOperation,
|
||||||
|
this.errorMessage,
|
||||||
|
this.query = '',
|
||||||
|
this.dateRange,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.isSavingDraft = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
OperationsState copyWith({
|
||||||
|
OperationsStatus? status,
|
||||||
|
List<OperationModel>? allOperations,
|
||||||
|
OperationModel? currentOperation,
|
||||||
|
String? errorMessage,
|
||||||
|
String? query,
|
||||||
|
DateTimeRange? dateRange,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
bool? isSavingDraft,
|
||||||
|
}) {
|
||||||
|
return OperationsState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
allOperations: allOperations ?? this.allOperations,
|
||||||
|
currentOperation: currentOperation ?? this.currentOperation,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
query: query ?? this.query,
|
||||||
|
dateRange: dateRange ?? this.dateRange,
|
||||||
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
|
isSavingDraft: isSavingDraft ?? this.isSavingDraft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
status,
|
||||||
|
allOperations,
|
||||||
|
currentOperation,
|
||||||
|
errorMessage,
|
||||||
|
query,
|
||||||
|
dateRange,
|
||||||
|
hasReachedMax,
|
||||||
|
isSavingDraft,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class OperationsRepository {
|
|||||||
.from('operation')
|
.from('operation')
|
||||||
.select('''
|
.select('''
|
||||||
*,
|
*,
|
||||||
customer(*),
|
customer(name),
|
||||||
store(name),
|
store(name),
|
||||||
staff_member(name),
|
staff_member(name),
|
||||||
provider(name),
|
provider(name),
|
||||||
@@ -47,7 +47,7 @@ class OperationsRepository {
|
|||||||
.from('operation')
|
.from('operation')
|
||||||
.select('''
|
.select('''
|
||||||
*,
|
*,
|
||||||
customer(*),
|
customer(name),
|
||||||
store(name),
|
store(name),
|
||||||
provider(name),
|
provider(name),
|
||||||
model(name_with_brand),
|
model(name_with_brand),
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.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:flux/features/customers/models/customer_model.dart';
|
|
||||||
|
|
||||||
enum OperationStatus {
|
enum OperationStatus {
|
||||||
success('success', 'OK'),
|
ok('ok'),
|
||||||
waitingForAction('waiting_for_action', 'In attesa di azione'),
|
waitingforaction('waiting_for_action'),
|
||||||
waitingForSupport('waiting_for_support', 'In attesa di supporto'),
|
waitingforsupport('waiting_for_support'),
|
||||||
failure('failure', 'KO'),
|
waitingfordeployment('waiting_for_deployment'),
|
||||||
draft('draft', 'Bozza');
|
ko('ko'),
|
||||||
|
draft('draft'),
|
||||||
|
canceled('canceled');
|
||||||
|
|
||||||
static OperationStatus fromString(String value) {
|
static OperationStatus fromString(String value) {
|
||||||
final normalizedValue = value.replaceAll('_', '').toLowerCase();
|
final normalizedValue = value.replaceAll('_', '').toLowerCase();
|
||||||
@@ -18,9 +19,8 @@ enum OperationStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String supabaseName;
|
final String supabaseName;
|
||||||
final String displayName;
|
|
||||||
|
|
||||||
const OperationStatus(this.supabaseName, this.displayName);
|
const OperationStatus(this.supabaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
class OperationModel extends Equatable {
|
class OperationModel extends Equatable {
|
||||||
@@ -46,7 +46,7 @@ class OperationModel extends Equatable {
|
|||||||
final String? lastCampaignId;
|
final String? lastCampaignId;
|
||||||
final OperationStatus status;
|
final OperationStatus status;
|
||||||
final String? customerId;
|
final String? customerId;
|
||||||
final CustomerModel? customer;
|
final String? customerDisplayName;
|
||||||
final String reference;
|
final String reference;
|
||||||
|
|
||||||
// ALLEGATI (Aggiunto)
|
// ALLEGATI (Aggiunto)
|
||||||
@@ -75,7 +75,7 @@ class OperationModel extends Equatable {
|
|||||||
this.lastCampaignId,
|
this.lastCampaignId,
|
||||||
this.status = OperationStatus.draft,
|
this.status = OperationStatus.draft,
|
||||||
this.customerId,
|
this.customerId,
|
||||||
this.customer,
|
this.customerDisplayName,
|
||||||
this.reference = '',
|
this.reference = '',
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,7 @@ class OperationModel extends Equatable {
|
|||||||
String? lastCampaignId,
|
String? lastCampaignId,
|
||||||
OperationStatus? status,
|
OperationStatus? status,
|
||||||
String? customerId,
|
String? customerId,
|
||||||
CustomerModel? customer,
|
String? customerDisplayName,
|
||||||
String? reference,
|
String? reference,
|
||||||
List<AttachmentModel>? attachments,
|
List<AttachmentModel>? attachments,
|
||||||
}) => OperationModel(
|
}) => OperationModel(
|
||||||
@@ -129,7 +129,7 @@ class OperationModel extends Equatable {
|
|||||||
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
|
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
customerId: customerId ?? this.customerId,
|
customerId: customerId ?? this.customerId,
|
||||||
customer: customer ?? this.customer,
|
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||||
reference: reference ?? this.reference,
|
reference: reference ?? this.reference,
|
||||||
attachments: attachments ?? this.attachments,
|
attachments: attachments ?? this.attachments,
|
||||||
);
|
);
|
||||||
@@ -158,13 +158,13 @@ class OperationModel extends Equatable {
|
|||||||
lastCampaignId,
|
lastCampaignId,
|
||||||
status,
|
status,
|
||||||
customerId,
|
customerId,
|
||||||
customer,
|
customerDisplayName,
|
||||||
reference,
|
reference,
|
||||||
attachments,
|
attachments,
|
||||||
];
|
];
|
||||||
|
|
||||||
factory OperationModel.empty() {
|
factory OperationModel.empty({required String companyId}) {
|
||||||
return OperationModel(id: null, createdAt: null, companyId: '');
|
return OperationModel(id: null, createdAt: null, companyId: companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory OperationModel.fromMap(Map<String, dynamic> map) {
|
factory OperationModel.fromMap(Map<String, dynamic> map) {
|
||||||
@@ -208,11 +208,9 @@ class OperationModel extends Equatable {
|
|||||||
|
|
||||||
lastCampaignId: map['last_campaign_id'] as String?,
|
lastCampaignId: map['last_campaign_id'] as String?,
|
||||||
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
||||||
customerId: map['customer_id'] as String?,
|
|
||||||
|
|
||||||
customer: map['customer'] != null
|
customerId: map['customer_id'] as String?,
|
||||||
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
|
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
|
||||||
: null,
|
|
||||||
|
|
||||||
attachments:
|
attachments:
|
||||||
(map['attachment'] as List?)
|
(map['attachment'] as List?)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
|
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
|
||||||
import 'package:flux/features/operations/ui/widgets/details_section.dart';
|
import 'package:flux/features/operations/ui/widgets/details_section.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
|
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
|
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
|
||||||
|
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
|
||||||
|
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
|
||||||
|
|
||||||
class OperationFormScreen extends StatefulWidget {
|
class OperationFormScreen extends StatefulWidget {
|
||||||
final String? operationId;
|
final String? operationId;
|
||||||
@@ -47,10 +49,26 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
context.read<OperationFormCubit>().initForm(
|
final cubit = context.read<OperationsCubit>();
|
||||||
|
final currentLoggedStaff = GetIt.I
|
||||||
|
.get<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStaffMember!;
|
||||||
|
|
||||||
|
// 1. Diciamo al Cubit di prepararsi
|
||||||
|
cubit.initOperationForm(
|
||||||
existingOperation: widget.existingOperation,
|
existingOperation: widget.existingOperation,
|
||||||
operationId: widget.operationId,
|
operationId: widget.operationId,
|
||||||
|
staffId: currentLoggedStaff.id,
|
||||||
|
staffDisplayName: currentLoggedStaff.name,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2. IL TRUCCO MAGICO:
|
||||||
|
// Se abbiamo passato existingOperation, il Cubit si è appena aggiornato.
|
||||||
|
// Lo stato è già pronto, quindi sincronizziamo i controller SUBITO!
|
||||||
|
if (cubit.state.currentOperation != null) {
|
||||||
|
_syncTextControllers(cubit.state.currentOperation!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -58,111 +76,84 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
_referenceController.dispose();
|
_referenceController.dispose();
|
||||||
_noteController.dispose();
|
_noteController.dispose();
|
||||||
_freeTextSubtypeController.dispose();
|
_freeTextSubtypeController.dispose();
|
||||||
_freeTextDescriptionController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncTextControllers(OperationModel model) {
|
void _syncTextControllers(OperationModel model) {
|
||||||
if (_referenceController.text.isEmpty) {
|
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
|
||||||
_referenceController.text = model.reference;
|
_referenceController.text = model.reference;
|
||||||
}
|
}
|
||||||
if (_noteController.text.isEmpty) {
|
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
|
||||||
_noteController.text = model.note;
|
_noteController.text = model.note;
|
||||||
}
|
}
|
||||||
if (_freeTextSubtypeController.text.isEmpty) {
|
if (_freeTextSubtypeController.text.isEmpty &&
|
||||||
_freeTextSubtypeController.text = model.subtype ?? '';
|
model.subtype != null &&
|
||||||
|
model.subtype!.isNotEmpty) {
|
||||||
|
_freeTextSubtypeController.text = model.subtype!;
|
||||||
}
|
}
|
||||||
if (_freeTextDescriptionController.text.isEmpty) {
|
if (_freeTextDescriptionController.text.isEmpty &&
|
||||||
_freeTextDescriptionController.text = model.description ?? '';
|
model.description != null &&
|
||||||
|
model.description!.isNotEmpty) {
|
||||||
|
_freeTextDescriptionController.text = model.description!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se è una nuova pratica (draft), impostiamo di default il target su OK per comodità UI
|
|
||||||
if (model.id == null && model.status == OperationStatus.draft) {
|
|
||||||
// Usiamo addPostFrameCallback per non interferire con il build attuale
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
// Supponendo tu aggiunga la possibilità di aggiornare lo status nel metodo updateFields del Cubit
|
|
||||||
// context.read<OperationFormCubit>().updateFields(status: OperationStatus.ok);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flushControllersToCubit() {
|
void _saveOperation({required bool keepAdding}) {
|
||||||
context.read<OperationFormCubit>().updateFields(
|
if (_formKey.currentState!.validate()) {
|
||||||
|
final cubit = context.read<OperationsCubit>();
|
||||||
|
final currentOperation = cubit.state.currentOperation!;
|
||||||
|
|
||||||
|
final operationToSave = currentOperation.copyWith(
|
||||||
reference: _referenceController.text,
|
reference: _referenceController.text,
|
||||||
note: _noteController.text,
|
note: _noteController.text,
|
||||||
subtype: _freeTextSubtypeController.text,
|
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
|
||||||
description: _freeTextDescriptionController.text,
|
? _freeTextSubtypeController.text
|
||||||
|
: currentOperation.subtype,
|
||||||
|
description: ['Energy', 'Custom'].contains(currentOperation.type)
|
||||||
|
? _freeTextDescriptionController.text
|
||||||
|
: currentOperation.description,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
void _saveOperation({
|
cubit.initOperationForm(existingOperation: operationToSave);
|
||||||
required OperationStatus targetStatus,
|
cubit.saveCurrentOperation(
|
||||||
required bool keepAdding,
|
targetStatus: OperationStatus.ok,
|
||||||
}) {
|
shouldPop: !keepAdding,
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
_flushControllersToCubit();
|
|
||||||
context.read<OperationFormCubit>().saveOperation(
|
|
||||||
targetStatus: targetStatus,
|
|
||||||
keepAdding: keepAdding,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _generateIdForQr() async {
|
|
||||||
if (!_formKey.currentState!.validate()) return null;
|
|
||||||
_flushControllersToCubit();
|
|
||||||
final attachmentsBloc = context.read<AttachmentsBloc>();
|
|
||||||
// Presumo tu abbia creato il metodo saveOperationDraft() nel Cubit!
|
|
||||||
final newId = await context.read<OperationFormCubit>().saveOperationDraft();
|
|
||||||
if (newId != null && context.mounted) {
|
|
||||||
attachmentsBloc.add(ParentEntitySavedEvent(newId));
|
|
||||||
}
|
|
||||||
return newId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper per assegnare un colore agli stati
|
|
||||||
Color _getStatusColor(OperationStatus status) {
|
|
||||||
switch (status) {
|
|
||||||
case OperationStatus.success:
|
|
||||||
return Colors.green;
|
|
||||||
case OperationStatus.waitingForAction:
|
|
||||||
return Colors.orange;
|
|
||||||
case OperationStatus.waitingForSupport:
|
|
||||||
return Colors.blue;
|
|
||||||
case OperationStatus.failure:
|
|
||||||
return Colors.red;
|
|
||||||
case OperationStatus.draft:
|
|
||||||
return Colors.grey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return BlocConsumer<OperationFormCubit, OperationFormState>(
|
return BlocConsumer<OperationsCubit, OperationsState>(
|
||||||
listenWhen: (previous, current) => previous.status != current.status,
|
listenWhen: (previous, current) =>
|
||||||
|
previous.status != current.status ||
|
||||||
|
previous.currentOperation?.id != current.currentOperation?.id,
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state.status == OperationFormStatus.ready && !_isInitialized) {
|
if (state.status == OperationsStatus.ready &&
|
||||||
_syncTextControllers(state.operation);
|
state.currentOperation != null &&
|
||||||
|
!_isInitialized) {
|
||||||
|
_syncTextControllers(state.currentOperation!);
|
||||||
}
|
}
|
||||||
if (state.status == OperationFormStatus.success) {
|
|
||||||
|
if (state.status == OperationsStatus.saved) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else if (state.status == OperationFormStatus.successAndAddAnother) {
|
} else if (state.status == OperationsStatus.savedNoPop) {
|
||||||
|
context.read<OperationsCubit>().prepareNextOperationInBatch();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Operazione salvata! Inserisci la prossima'),
|
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_freeTextSubtypeController.clear();
|
_freeTextSubtypeController.clear();
|
||||||
_freeTextDescriptionController.clear();
|
_freeTextDescriptionController.clear();
|
||||||
} else if (state.status == OperationFormStatus.failure) {
|
} else if (state.status == OperationsStatus.failure) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
content: Text(state.errorMessage ?? 'Errore'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,47 +161,19 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (!_isInitialized &&
|
if (!_isInitialized &&
|
||||||
(widget.operationId != null || widget.existingOperation != null) &&
|
(widget.operationId != null || widget.existingOperation != null) &&
|
||||||
state.status == OperationFormStatus.loading) {
|
state.status == OperationsStatus.loading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determiniamo lo stato da mostrare nel form.
|
|
||||||
// Se è una bozza appena creata, mostriamo visivamente "OK" come default per il salvataggio.
|
|
||||||
final displayStatus =
|
|
||||||
state.operation.status == OperationStatus.draft &&
|
|
||||||
state.operation.id == null
|
|
||||||
? OperationStatus.success
|
|
||||||
: state.operation.status;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
state.operation.id == null
|
state.currentOperation?.id == null
|
||||||
? 'Nuova Pratica - Operatore: ${state.operation.staffDisplayName}'
|
? 'Nuova Pratica'
|
||||||
: 'Modifica Pratica - Operatore: ${state.operation.staffDisplayName}',
|
: 'Modifica Pratica',
|
||||||
),
|
),
|
||||||
// Mettiamo un piccolo indicatore visivo anche nella AppBar se non è OK
|
|
||||||
actions:
|
|
||||||
displayStatus != OperationStatus.success &&
|
|
||||||
displayStatus != OperationStatus.draft
|
|
||||||
? [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
|
||||||
child: Chip(
|
|
||||||
label: Text(
|
|
||||||
displayStatus.displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
backgroundColor: _getStatusColor(displayStatus),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
body: Form(
|
body: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -219,7 +182,47 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
final isUltraWide = constraints.maxWidth > 1400;
|
final isUltraWide = constraints.maxWidth > 1400;
|
||||||
final isDesktop = constraints.maxWidth > 900;
|
final isDesktop = constraints.maxWidth > 900;
|
||||||
if (isUltraWide) {
|
if (isUltraWide) {
|
||||||
return _buildUltraWide(state, theme);
|
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 1. FORM PRINCIPALE (40%)
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
|
||||||
|
child: _buildMainFormContent(
|
||||||
|
theme,
|
||||||
|
state,
|
||||||
|
showFiles: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||||
|
|
||||||
|
// 2. NOTE (30%)
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: _buildNotesSection(isDesktop: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||||
|
|
||||||
|
// 3. FILE (30%)
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: OperationFilesSection(
|
||||||
|
currentOp: state.currentOperation!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
} else if (isDesktop) {
|
} else if (isDesktop) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -228,11 +231,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
flex: 7,
|
flex: 7,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: _buildMainFormContent(
|
child: _buildMainFormContent(theme, state),
|
||||||
theme,
|
|
||||||
state,
|
|
||||||
displayStatus,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||||
@@ -251,7 +250,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildMainFormContent(theme, state, displayStatus),
|
_buildMainFormContent(theme, state),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
_buildNotesSection(isDesktop: false),
|
_buildNotesSection(isDesktop: false),
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 80),
|
||||||
@@ -269,28 +268,24 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 1,
|
flex: 1,
|
||||||
child: ElevatedButton(
|
child: OutlinedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
onPressed: state.status == OperationsStatus.saving
|
||||||
// Se c'è un KO o un blocco, cambiamo il colore del bottone principale per attirare l'attenzione
|
|
||||||
backgroundColor:
|
|
||||||
displayStatus != OperationStatus.success &&
|
|
||||||
displayStatus != OperationStatus.draft
|
|
||||||
? _getStatusColor(displayStatus)
|
|
||||||
: null,
|
|
||||||
foregroundColor:
|
|
||||||
displayStatus != OperationStatus.success &&
|
|
||||||
displayStatus != OperationStatus.draft
|
|
||||||
? Colors.white
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
onPressed: state.status == OperationFormStatus.saving
|
|
||||||
? null
|
? null
|
||||||
: () => _saveOperation(
|
: () => _saveOperation(keepAdding: true),
|
||||||
keepAdding: false,
|
child: const Text(
|
||||||
targetStatus:
|
'Salva e Aggiungi Altro',
|
||||||
displayStatus, // <-- Usiamo lo stato selezionato nel form!
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
child: state.status == OperationFormStatus.saving
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: state.status == OperationsStatus.saving
|
||||||
|
? null
|
||||||
|
: () => _saveOperation(keepAdding: false),
|
||||||
|
child: state.status == OperationsStatus.saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -302,24 +297,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
: const Text('Salva ed Esci'),
|
: const Text('Salva ed Esci'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: state.status == OperationFormStatus.saving
|
|
||||||
? null
|
|
||||||
: () => _saveOperation(
|
|
||||||
keepAdding: true,
|
|
||||||
targetStatus:
|
|
||||||
displayStatus, // <-- Usiamo lo stato selezionato nel form!
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Salva e Aggiungi Altro',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -329,166 +306,31 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUltraWide(OperationFormState state, ThemeData theme) {
|
Widget _buildMainFormContent(
|
||||||
return Row(
|
ThemeData theme,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
OperationsState state, {
|
||||||
children: [
|
bool showFiles = true,
|
||||||
Expanded(
|
}) {
|
||||||
flex: 4,
|
final currentOp = state.currentOperation;
|
||||||
child: SingleChildScrollView(
|
final currentType = currentOp?.type ?? 'AL';
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildStaffSection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
_buildOperationStatusSection(state),
|
|
||||||
const Divider(height: 32),
|
|
||||||
_buildCustomerSection(state),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildReferenceSection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
_buildOperationTypeSection(state),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildQuantitySection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
_buildDetailsSection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: _buildNotesSection(isDesktop: true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: _buildAttachmentSection(state),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStaffSection(OperationFormState state) {
|
|
||||||
return StaffSection(
|
|
||||||
staffId: state.operation.staffId,
|
|
||||||
staffName: state.operation.staffDisplayName,
|
|
||||||
onStaffSelected: (staff) => {
|
|
||||||
context.read<OperationFormCubit>().updateFields(
|
|
||||||
staffId: staff.id,
|
|
||||||
staffDisplayName: staff.name,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOperationStatusSection(OperationFormState state) {
|
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Esito / Stato Operazione'),
|
StaffSection(currentOp: currentOp),
|
||||||
Container(
|
const Divider(height: 50),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
_buildSectionTitle('Cliente & Riferimento'),
|
||||||
decoration: BoxDecoration(
|
CustomerSection(currentOp: currentOp),
|
||||||
color: _getStatusColor(
|
const SizedBox(height: 16),
|
||||||
state.operation.status,
|
TextFormField(
|
||||||
).withValues(alpha: 0.1),
|
|
||||||
border: Border.all(
|
|
||||||
color: _getStatusColor(
|
|
||||||
state.operation.status,
|
|
||||||
).withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: DropdownButtonHideUnderline(
|
|
||||||
child: DropdownButton<OperationStatus>(
|
|
||||||
isExpanded: true,
|
|
||||||
value: state.operation.status,
|
|
||||||
icon: Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
color: _getStatusColor(state.operation.status),
|
|
||||||
),
|
|
||||||
items: OperationStatus.values
|
|
||||||
/* .where(
|
|
||||||
(s) => s != OperationStatus.draft,
|
|
||||||
) // Nascondiamo 'Bozza' dal menu */
|
|
||||||
.map(
|
|
||||||
(status) => DropdownMenuItem(
|
|
||||||
value: status,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
status == OperationStatus.success
|
|
||||||
? Icons.check_circle
|
|
||||||
: Icons.error_outline,
|
|
||||||
color: _getStatusColor(status),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
status.displayName,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _getStatusColor(status),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (newStatus) {
|
|
||||||
if (newStatus != null) {
|
|
||||||
// Assicurati che il metodo updateFields nel tuo Cubit accetti anche 'status'
|
|
||||||
context.read<OperationFormCubit>().updateFields(
|
|
||||||
status: newStatus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
state.operation.status == OperationStatus.success
|
|
||||||
? 'Lascia OK se la pratica è stata caricata con successo.'
|
|
||||||
: 'Attenzione: la pratica verrà salvata come ${state.operation.status.displayName}.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCustomerSection(OperationFormState state) {
|
|
||||||
return SharedCustomerSection(
|
|
||||||
customer: state.operation.customer,
|
|
||||||
onCustomerSelected: (customer) {
|
|
||||||
context.read<OperationFormCubit>().updateFields(customer: customer);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildReferenceSection(OperationFormState state) {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _referenceController,
|
controller: _referenceController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Riferimento (es. numero di telefono, targa...)',
|
labelText: 'Riferimento (es. numero di telefono, targa...)',
|
||||||
prefixIcon: Icon(Icons.tag),
|
prefixIcon: Icon(Icons.tag),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
const Divider(height: 32),
|
||||||
|
|
||||||
Widget _buildOperationTypeSection(OperationFormState state) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_buildSectionTitle('Cosa stiamo facendo?'),
|
_buildSectionTitle('Cosa stiamo facendo?'),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8.0,
|
spacing: 8.0,
|
||||||
@@ -496,101 +338,59 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
children: _availableTypes.map((type) {
|
children: _availableTypes.map((type) {
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(type),
|
label: Text(type),
|
||||||
selected: state.operation.type == type,
|
selected: currentType == type,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
context.read<OperationFormCubit>().setTypeWithSmartDefault(
|
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
|
||||||
type,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
],
|
const Divider(height: 32),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailsSection(OperationFormState state) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_buildSectionTitle('Dettagli Servizio'),
|
_buildSectionTitle('Dettagli Servizio'),
|
||||||
DetailsSection(
|
DetailsSection(
|
||||||
currentOp: state.operation,
|
currentOp: currentOp,
|
||||||
currentType: state.operation.type,
|
currentType: currentType,
|
||||||
freeTextSubtypeController: _freeTextSubtypeController,
|
freeTextSubtypeController: _freeTextSubtypeController,
|
||||||
freeTextDescriptionController: _freeTextDescriptionController,
|
freeTextDescriptionController: _freeTextDescriptionController,
|
||||||
durationQuickPicks: _buildDurationQuickPicks(state.operation),
|
durationQuickPicks: _buildDurationQuickPicks(currentOp),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuantitySection(OperationFormState state) {
|
// QUANTITÀ
|
||||||
return Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Quantità: '),
|
const Text('Quantità: '),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.remove),
|
icon: const Icon(Icons.remove),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final q = state.operation.quantity;
|
final q = currentOp?.quantity ?? 1;
|
||||||
if (q > 1) {
|
if (q > 1) {
|
||||||
context.read<OperationFormCubit>().updateFields(quantity: q - 1);
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
quantity: q - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${state.operation.quantity}',
|
'${currentOp?.quantity ?? 1}',
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final q = state.operation.quantity;
|
final q = currentOp?.quantity ?? 1;
|
||||||
context.read<OperationFormCubit>().updateFields(quantity: q + 1);
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
quantity: q + 1,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAttachmentSection(OperationFormState state) {
|
|
||||||
return SharedAttachmentsSection(
|
|
||||||
parentType: AttachmentParentType.operation,
|
|
||||||
parentId: state.operation.id,
|
|
||||||
titleForUpload: state.operation.customer?.name ?? 'Nuova pratica',
|
|
||||||
onGenerateIdForQr: _generateIdForQr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMainFormContent(
|
|
||||||
ThemeData theme,
|
|
||||||
OperationFormState state,
|
|
||||||
OperationStatus displayStatus, {
|
|
||||||
bool showFiles = true,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
/* _buildStaffSection(state),
|
|
||||||
const Divider(height: 50), */
|
|
||||||
_buildOperationStatusSection(state),
|
|
||||||
const Divider(height: 32),
|
|
||||||
_buildCustomerSection(state),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildReferenceSection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
_buildOperationTypeSection(state),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildQuantitySection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
_buildDetailsSection(state),
|
|
||||||
const Divider(height: 50),
|
|
||||||
|
|
||||||
// QUANTITÀ
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
|
|
||||||
if (showFiles) ...[_buildAttachmentSection(state)],
|
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -620,7 +420,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
backgroundColor: Colors.blue.withValues(alpha: 0.05),
|
backgroundColor: Colors.blue.withValues(alpha: 0.05),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
context.read<OperationFormCubit>().updateFields(
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
expirationDate: DateTime(
|
expirationDate: DateTime(
|
||||||
now.year,
|
now.year,
|
||||||
now.month + months,
|
now.month + months,
|
||||||
|
|||||||
303
lib/features/operations/ui/operation_mobile_upload_screen.dart
Normal file
303
lib/features/operations/ui/operation_mobile_upload_screen.dart
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
|
class OperationMobileUploadScreen extends StatefulWidget {
|
||||||
|
final String operationId;
|
||||||
|
final String operationName;
|
||||||
|
|
||||||
|
const OperationMobileUploadScreen({
|
||||||
|
super.key,
|
||||||
|
required this.operationId,
|
||||||
|
required this.operationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OperationMobileUploadScreen> createState() =>
|
||||||
|
_OperationMobileUploadScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OperationMobileUploadScreenState
|
||||||
|
extends State<OperationMobileUploadScreen> {
|
||||||
|
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||||
|
final List<PlatformFile> _stagedFiles = [];
|
||||||
|
|
||||||
|
// 2. STATO DI CARICAMENTO GLOBALE
|
||||||
|
bool _isUploading = false;
|
||||||
|
|
||||||
|
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
||||||
|
bool _isImage(String path) {
|
||||||
|
final ext = path.split('.').last.toLowerCase();
|
||||||
|
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<OperationFilesBloc, OperationFilesState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||||
|
if (state.status == OperationFilesStatus.success && _isUploading) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("Tutti i file caricati con successo! ✅"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
if (state.status == OperationFilesStatus.failure) {
|
||||||
|
setState(() => _isUploading = false);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text("Upload Pratica:\n${widget.operationName}"),
|
||||||
|
automaticallyImplyLeading: !_isUploading,
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isUploading ? null : _handleCamera,
|
||||||
|
icon: const Icon(Icons.camera_alt),
|
||||||
|
label: const Text("SCATTA"),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isUploading ? null : _handleFilePicker,
|
||||||
|
icon: const Icon(Icons.folder),
|
||||||
|
label: const Text("GALLERIA"),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
|
||||||
|
Expanded(
|
||||||
|
child: _stagedFiles.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount:
|
||||||
|
3, // 3 colonne come la galleria dell'iPhone
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
itemCount: _stagedFiles.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _stagedFiles[index];
|
||||||
|
final isImg = _isImage(file.name);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
// L'ANTEPRIMA
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: isImg
|
||||||
|
? Image.file(
|
||||||
|
File(file.path!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: const Column(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.picture_as_pdf,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
"PDF",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// IL PULSANTE CESTINO (In alto a destra)
|
||||||
|
Positioned(
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_stagedFiles.removeAt(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- SEZIONE INVIA E CHIUDI ---
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
// Il pulsante si accende SOLO se ci sono file nel carrello
|
||||||
|
onPressed: _stagedFiles.isEmpty || _isUploading
|
||||||
|
? null
|
||||||
|
: _submitAllFiles,
|
||||||
|
icon: const Icon(Icons.cloud_upload),
|
||||||
|
label: Text(
|
||||||
|
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
foregroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
||||||
|
if (_isUploading)
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
child: const Center(
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
"Caricamento in corso...",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
||||||
|
Future<void> _handleCamera() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final photo = await picker.pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
if (photo != null) {
|
||||||
|
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
||||||
|
final photoSize = await photo.length();
|
||||||
|
|
||||||
|
final platformFile = PlatformFile(
|
||||||
|
name: photo.name,
|
||||||
|
size: photoSize,
|
||||||
|
path: photo.path,
|
||||||
|
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleFilePicker() async {
|
||||||
|
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
|
||||||
|
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||||
|
if (result != null) {
|
||||||
|
setState(() {
|
||||||
|
_stagedFiles.addAll(result.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA DI INVIO AL BLoC ---
|
||||||
|
void _submitAllFiles() {
|
||||||
|
setState(() => _isUploading = true);
|
||||||
|
|
||||||
|
// Diciamo al BLoC di caricare tutti i file.
|
||||||
|
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
||||||
|
final bloc = context.read<OperationFilesBloc>();
|
||||||
|
bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
|
||||||
|
|
||||||
|
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
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/routes/routes.dart';
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
// Importa i tuoi modelli e cubit
|
// Importa i tuoi modelli e cubit
|
||||||
|
|
||||||
class OperationListScreen extends StatefulWidget {
|
class OperationsScreen extends StatefulWidget {
|
||||||
const OperationListScreen({super.key});
|
const OperationsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OperationListScreen> createState() => _OperationListScreenState();
|
State<OperationsScreen> createState() => _OperationsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OperationListScreenState extends State<OperationListScreen> {
|
class _OperationsScreenState extends State<OperationsScreen> {
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -23,11 +20,13 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
// Carichiamo i servizi iniziali
|
||||||
|
context.read<OperationsCubit>().loadOperations();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (_isBottom) {
|
if (_isBottom) {
|
||||||
context.read<OperationListCubit>().loadOperations();
|
context.read<OperationsCubit>().loadOperations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +59,16 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: BlocBuilder<OperationListCubit, OperationListState>(
|
body: BlocBuilder<OperationsCubit, OperationsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// 1. Stato di caricamento iniziale
|
// 1. Stato di caricamento iniziale
|
||||||
if (state.status == OperationListStatus.loading &&
|
if (state.status == OperationsStatus.loading &&
|
||||||
state.operations.isEmpty) {
|
state.allOperations.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lista vuota
|
// 2. Lista vuota
|
||||||
if (state.operations.isEmpty) {
|
if (state.allOperations.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -78,7 +77,7 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context
|
onPressed: () => context
|
||||||
.read<OperationListCubit>()
|
.read<OperationsCubit>()
|
||||||
.loadOperations(refresh: true),
|
.loadOperations(refresh: true),
|
||||||
child: const Text("Riprova"),
|
child: const Text("Riprova"),
|
||||||
),
|
),
|
||||||
@@ -89,17 +88,16 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
|
|
||||||
// 3. La Lista (con Pull-to-refresh)
|
// 3. La Lista (con Pull-to-refresh)
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => context.read<OperationListCubit>().loadOperations(
|
onRefresh: () =>
|
||||||
refresh: true,
|
context.read<OperationsCubit>().loadOperations(refresh: true),
|
||||||
),
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
|
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
|
||||||
itemCount: state.hasReachedMax
|
itemCount: state.hasReachedMax
|
||||||
? state.operations.length
|
? state.allOperations.length
|
||||||
: state.operations.length + 1,
|
: state.allOperations.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index >= state.operations.length) {
|
if (index >= state.allOperations.length) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
@@ -108,7 +106,7 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final operation = state.operations[index];
|
final operation = state.allOperations[index];
|
||||||
return _buildOperationCard(context, operation);
|
return _buildOperationCard(context, operation);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -116,15 +114,7 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () async {
|
onPressed: () => startNewOperation(context),
|
||||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
|
||||||
if (createdBy == null || !context.mounted) return;
|
|
||||||
context.pushNamed(
|
|
||||||
Routes.operationForm,
|
|
||||||
pathParameters: {'id': 'new'},
|
|
||||||
extra: (createdBy: createdBy, operation: null),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -141,7 +131,7 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
operation.customer?.name ?? "Cliente sconosciuto",
|
operation.customerDisplayName ?? "Cliente sconosciuto",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -169,7 +159,7 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.pushNamed(
|
||||||
'operations/form/id=${operation.id}',
|
'operation-form',
|
||||||
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
||||||
// Teniamo anche il parametro URL per coerenza di routing
|
// Teniamo anche il parametro URL per coerenza di routing
|
||||||
queryParameters: operation.id != null
|
queryParameters: operation.id != null
|
||||||
@@ -183,16 +173,17 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
Widget _buildOperationStatus(OperationStatus status) {
|
Widget _buildOperationStatus(OperationStatus status) {
|
||||||
Color color;
|
Color color;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case OperationStatus.failure:
|
case OperationStatus.canceled || OperationStatus.ko:
|
||||||
color = Colors.grey.shade800;
|
color = Colors.grey.shade800;
|
||||||
break;
|
break;
|
||||||
case OperationStatus.waitingForAction || OperationStatus.draft:
|
case OperationStatus.waitingforaction || OperationStatus.draft:
|
||||||
color = Colors.orange;
|
color = Colors.orange;
|
||||||
break;
|
break;
|
||||||
case OperationStatus.success:
|
case OperationStatus.ok:
|
||||||
color = Colors.green;
|
color = Colors.green;
|
||||||
break;
|
break;
|
||||||
case OperationStatus.waitingForSupport:
|
case OperationStatus.waitingfordeployment ||
|
||||||
|
OperationStatus.waitingforsupport:
|
||||||
color = Colors.blue;
|
color = Colors.blue;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -204,6 +195,6 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void startNewOperation(BuildContext context) {
|
void startNewOperation(BuildContext context) {
|
||||||
context.pushNamed('operation-form', pathParameters: {'id': 'new'});
|
context.pushNamed('operation-form');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
|
||||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
import 'package:flux/features/customers/blocs/customers_cubit.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:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
class SharedCustomerSection extends StatelessWidget {
|
class CustomerSection extends StatelessWidget {
|
||||||
final CustomerModel? customer;
|
final OperationModel? currentOp;
|
||||||
final ValueChanged<CustomerModel> onCustomerSelected;
|
const CustomerSection({super.key, required this.currentOp});
|
||||||
|
|
||||||
const SharedCustomerSection({
|
|
||||||
super.key,
|
|
||||||
this.customer,
|
|
||||||
required this.onCustomerSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasCustomer = customer != null && customer!.id!.isNotEmpty;
|
final hasCustomer =
|
||||||
|
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -50,7 +41,9 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
hasCustomer ? customer!.name : 'Seleziona Cliente *',
|
hasCustomer
|
||||||
|
? currentOp!.customerDisplayName!
|
||||||
|
: 'Seleziona Cliente *',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: hasCustomer
|
fontWeight: hasCustomer
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
@@ -60,145 +53,10 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.search),
|
const Icon(Icons.search),
|
||||||
|
|
||||||
if (hasCustomer) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => context.pushNamed(
|
|
||||||
Routes.customerForm,
|
|
||||||
pathParameters: {'id': customer!.id!},
|
|
||||||
extra: customer,
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hasCustomer &&
|
|
||||||
(customer!.phoneNumber.isNotEmpty ||
|
|
||||||
customer!.email.isNotEmpty)) ...[
|
|
||||||
const SizedBox(height: 12), // Un po' più di respiro dal box sopra
|
|
||||||
// Mettiamo i contatti in un Container con un po' di stile per farli sembrare una "Contact Card" integrata
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.05), // Sfondo leggerissimo
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// --- RIGA TELEFONO ---
|
|
||||||
if (customer!.phoneNumber.isNotEmpty)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
// Usiamo i pulsanti "Small" per non occupare troppo spazio verticale
|
|
||||||
IconButton(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () => launchUrl(
|
|
||||||
Uri.parse('https://wa.me/39${customer!.phoneNumber}'),
|
|
||||||
),
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.whatsapp,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
tooltip: 'Invia WhatsApp',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
// Expanded evita l'overflow se il numero è assurdamente lungo
|
|
||||||
child: SelectableText(
|
|
||||||
customer!.phoneNumber,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(
|
|
||||||
ClipboardData(text: customer!.phoneNumber),
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Telefono copiato!'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.copy,
|
|
||||||
size: 18,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
tooltip: 'Copia',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// Sezione divisoria se ci sono entrambi
|
|
||||||
if (customer!.phoneNumber.isNotEmpty &&
|
|
||||||
customer!.email.isNotEmpty)
|
|
||||||
const Divider(height: 8, thickness: 0.5),
|
|
||||||
|
|
||||||
// --- RIGA EMAIL ---
|
|
||||||
if (customer!.email.isNotEmpty)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () => launchUrl(
|
|
||||||
Uri.parse('mailto:${customer!.email}'),
|
|
||||||
), // Rimosso il // dopo mailto:, è più sicuro
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.envelope,
|
|
||||||
color: Colors.blue,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
tooltip: 'Invia Email',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
// L'Expanded è vitale per le email che possono essere lunghissime
|
|
||||||
child: SelectableText(
|
|
||||||
customer!.email,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(
|
|
||||||
ClipboardData(text: customer!.email),
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Email copiata!'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.copy,
|
|
||||||
size: 18,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
tooltip: 'Copia',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,6 +125,9 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.person_add),
|
icon: const Icon(Icons.person_add),
|
||||||
label: const Text('Crea Nuovo Cliente'),
|
label: const Text('Crea Nuovo Cliente'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
final OperationsCubit operationsCubit = context
|
||||||
|
.read<OperationsCubit>();
|
||||||
|
|
||||||
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
||||||
final newCustomer = await showDialog(
|
final newCustomer = await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -284,7 +145,10 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
// Se l'ha creato davvero (e non ha premuto annulla)...
|
// Se l'ha creato davvero (e non ha premuto annulla)...
|
||||||
if (newCustomer != null) {
|
if (newCustomer != null) {
|
||||||
// 1. Aggiorniamo il form delle operazioni
|
// 1. Aggiorniamo il form delle operazioni
|
||||||
onCustomerSelected(newCustomer);
|
operationsCubit.updateOperationFields(
|
||||||
|
customerId: newCustomer.id,
|
||||||
|
customerDisplayName: newCustomer.name,
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -332,7 +196,14 @@ class SharedCustomerSection extends StatelessWidget {
|
|||||||
'${customer.phoneNumber} • ${customer.email}',
|
'${customer.phoneNumber} • ${customer.email}',
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onCustomerSelected(customer);
|
// Aggiorniamo il form tramite il Cubit delle operazioni
|
||||||
|
context
|
||||||
|
.read<OperationsCubit>()
|
||||||
|
.updateOperationFields(
|
||||||
|
customerId: customer.id, // customer.id
|
||||||
|
customerDisplayName:
|
||||||
|
customer.name, // customer.name
|
||||||
|
);
|
||||||
Navigator.pop(modalContext);
|
Navigator.pop(modalContext);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
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/widgets/shared_forms/model_section.dart';
|
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||||
|
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
|
||||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
|
|
||||||
class DetailsSection extends StatelessWidget {
|
class DetailsSection extends StatelessWidget {
|
||||||
@@ -45,7 +46,6 @@ class DetailsSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showProviderModal(BuildContext context, String operationType) {
|
void _showProviderModal(BuildContext context, String operationType) {
|
||||||
final OperationFormCubit cubit = context.read<OperationFormCubit>();
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -104,9 +104,7 @@ class DetailsSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return BlocProvider.value(
|
return ListView.builder(
|
||||||
value: cubit,
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: filteredProviders.length,
|
itemCount: filteredProviders.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -120,7 +118,9 @@ class DetailsSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<OperationFormCubit>().updateFields(
|
context
|
||||||
|
.read<OperationsCubit>()
|
||||||
|
.updateOperationFields(
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
providerDisplayName: provider.name,
|
providerDisplayName: provider.name,
|
||||||
);
|
);
|
||||||
@@ -128,7 +128,129 @@ class DetailsSection extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showModelModal(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (modalContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scrollController) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Seleziona Modello',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(modalContext),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Cerca modello (es. iPhone 15...)',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (query) =>
|
||||||
|
context.read<ProductsCubit>().searchModels(query),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Aggiungi Modello al Volo'),
|
||||||
|
onPressed: () async {
|
||||||
|
final operationsCubit = context.read<OperationsCubit>();
|
||||||
|
final existingBrands = context
|
||||||
|
.read<ProductsCubit>()
|
||||||
|
.state
|
||||||
|
.brands;
|
||||||
|
|
||||||
|
final newModel = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: context.read<ProductsCubit>(),
|
||||||
|
child: QuickProductDialog(
|
||||||
|
existingBrands: existingBrands,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newModel != null) {
|
||||||
|
operationsCubit.updateOperationFields(
|
||||||
|
modelId: newModel.id,
|
||||||
|
modelDisplayName: newModel.nameWithBrand,
|
||||||
|
);
|
||||||
|
if (context.mounted) Navigator.pop(modalContext);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<ProductsCubit, ProductState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return ListView.builder(
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: state.models.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final deviceModel = state.models[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.devices),
|
||||||
|
title: Text(
|
||||||
|
deviceModel.nameWithBrand,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
context
|
||||||
|
.read<OperationsCubit>()
|
||||||
|
.updateOperationFields(
|
||||||
|
modelId: deviceModel.id,
|
||||||
|
modelDisplayName: deviceModel.nameWithBrand,
|
||||||
|
);
|
||||||
|
Navigator.pop(modalContext);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -192,7 +314,9 @@ class DetailsSection extends StatelessWidget {
|
|||||||
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) {
|
if (val != null) {
|
||||||
context.read<OperationFormCubit>().updateFields(subtype: val);
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
subtype: val,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -210,16 +334,30 @@ class DetailsSection extends StatelessWidget {
|
|||||||
|
|
||||||
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
|
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
|
||||||
if (currentType == 'Fin') ...[
|
if (currentType == 'Fin') ...[
|
||||||
SharedModelSection(
|
ListTile(
|
||||||
label: 'Seleziona Dispositivo/Prodotto',
|
title: const Text('Seleziona Dispositivo/Prodotto'),
|
||||||
modelId: currentOp?.modelId,
|
subtitle: Text(
|
||||||
modelName: currentOp?.modelDisplayName,
|
(currentOp?.modelDisplayName != null &&
|
||||||
onModelSelected: (id, name) {
|
currentOp!.modelDisplayName!.isNotEmpty)
|
||||||
context.read<OperationFormCubit>().updateFields(
|
? currentOp!.modelDisplayName!
|
||||||
modelId: id,
|
: 'Nessun modello selezionato',
|
||||||
modelDisplayName: name,
|
style: TextStyle(
|
||||||
);
|
color:
|
||||||
},
|
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
||||||
|
? Colors.grey
|
||||||
|
: null,
|
||||||
|
fontWeight:
|
||||||
|
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
||||||
|
? FontWeight.normal
|
||||||
|
: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_drop_down),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: theme.dividerColor),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
onTap: () => _showModelModal(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -271,7 +409,7 @@ class DetailsSection extends StatelessWidget {
|
|||||||
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||||
);
|
);
|
||||||
if (date != null && context.mounted) {
|
if (date != null && context.mounted) {
|
||||||
context.read<OperationFormCubit>().updateFields(
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
expirationDate: date,
|
expirationDate: date,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import 'package:flutter/foundation.dart';
|
|||||||
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: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/widgets/qr_upload_dialog.dart';
|
|
||||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||||
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
|
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
|
||||||
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
|
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
import 'package:flux/features/operations/blocs/operation_files_bloc.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';
|
||||||
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
|
||||||
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
|
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
|
||||||
|
|
||||||
class _ExportItem {
|
class _ExportItem {
|
||||||
@@ -30,26 +30,16 @@ class _ExportItem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class SharedAttachmentsSection extends StatefulWidget {
|
class OperationFilesSection extends StatefulWidget {
|
||||||
final String? parentId;
|
final OperationModel currentOp;
|
||||||
final String titleForUpload;
|
|
||||||
final AttachmentParentType parentType;
|
|
||||||
final Future<String?> Function()? onGenerateIdForQr;
|
|
||||||
|
|
||||||
const SharedAttachmentsSection({
|
const OperationFilesSection({super.key, required this.currentOp});
|
||||||
super.key,
|
|
||||||
this.parentId,
|
|
||||||
this.titleForUpload = 'Cliente_sconosciuto',
|
|
||||||
required this.parentType,
|
|
||||||
this.onGenerateIdForQr,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SharedAttachmentsSection> createState() =>
|
State<OperationFilesSection> createState() => _OperationFilesSectionState();
|
||||||
_SharedAttachmentsSectionState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
class _OperationFilesSectionState extends State<OperationFilesSection> {
|
||||||
String? _exportDirectory;
|
String? _exportDirectory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,7 +59,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
|
|
||||||
Future<void> _selectExportDirectory() async {
|
Future<void> _selectExportDirectory() async {
|
||||||
final String? selectedDirectory = await FilePicker.getDirectoryPath(
|
final String? selectedDirectory = await FilePicker.getDirectoryPath(
|
||||||
dialogTitle: 'Seleziona la cartella di esportazione',
|
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDirectory != null) {
|
if (selectedDirectory != null) {
|
||||||
@@ -99,14 +89,16 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
|
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
||||||
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
|
context.read<OperationFilesBloc>().add(
|
||||||
|
AddOperationFilesEvent(result.files),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- APERTURA VIEWER ---
|
// --- APERTURA VIEWER ---
|
||||||
void _openFile(AttachmentModel file) {
|
void _openFile(AttachmentModel file) {
|
||||||
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
||||||
final operationFilesBloc = context.read<AttachmentsBloc>();
|
final operationFilesBloc = context.read<OperationFilesBloc>();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -116,10 +108,10 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
attachment: file,
|
attachment: file,
|
||||||
onRename: (newName) {
|
onRename: (newName) {
|
||||||
// Spara l'evento al BLoC e lui farà il resto!
|
// Spara l'evento al BLoC e lui farà il resto!
|
||||||
operationFilesBloc.add(RenameAttachmentEvent(file, newName));
|
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
|
||||||
},
|
},
|
||||||
onDelete: () {
|
onDelete: () {
|
||||||
operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
|
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -192,8 +184,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
suggestedName = selectedFiles.first.name;
|
suggestedName = selectedFiles.first.name;
|
||||||
} else {
|
} else {
|
||||||
// Se sono più file uniti
|
// Se sono più file uniti
|
||||||
|
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
|
||||||
suggestedName = '${widget.titleForUpload}_Unito';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -290,7 +281,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
if (fileBytes == null) continue;
|
if (fileBytes == null) continue;
|
||||||
|
|
||||||
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
||||||
final baseName = file.name;
|
final baseName = file.name ?? 'Documento';
|
||||||
|
|
||||||
if (file.extension == 'pdf') {
|
if (file.extension == 'pdf') {
|
||||||
final document = await px.PdfDocument.openData(fileBytes);
|
final document = await px.PdfDocument.openData(fileBytes);
|
||||||
@@ -401,7 +392,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
// USIAMO IL TUO BLOC!
|
||||||
|
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final allFiles = state.allFiles;
|
final allFiles = state.allFiles;
|
||||||
final selectedFiles = state.selectedFiles;
|
final selectedFiles = state.selectedFiles;
|
||||||
@@ -424,7 +416,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 (Es. Citrix TIM)',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
@@ -451,77 +443,9 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add_photo_alternate),
|
icon: const Icon(Icons.add_photo_alternate),
|
||||||
label: const Text('Aggiungi File'),
|
label: const Text('Aggiungi File'),
|
||||||
onPressed: state.status == AttachmentsStatus.uploading
|
onPressed: state.status == OperationFilesStatus.uploading
|
||||||
? null
|
? null
|
||||||
: _pickFiles,
|
: _pickFiles,
|
||||||
/* : () {
|
|
||||||
final bloc = context.read<AttachmentsBloc>();
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => BlocProvider.value(
|
|
||||||
value: bloc,
|
|
||||||
child: SharedMobileUploadScreen(
|
|
||||||
title: widget.titleForUpload,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, */
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Tooltip(
|
|
||||||
message: 'Carica foto con lo smartphone',
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
|
||||||
color: theme.colorScheme.primary, // Sempre colorato!
|
|
||||||
onPressed: () async {
|
|
||||||
String? targetId = state.parentId;
|
|
||||||
|
|
||||||
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
|
|
||||||
if (targetId == null) {
|
|
||||||
if (widget.onGenerateIdForQr != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Salvataggio rapido scheda in corso... ⏳',
|
|
||||||
),
|
|
||||||
duration: Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
|
|
||||||
targetId = await widget.onGenerateIdForQr!();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se fallisce (es. validazione form non passata), ci fermiamo
|
|
||||||
if (targetId == null) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GENERAZIONE DEL DEEP LINK AGNOSTICO
|
|
||||||
final companyId = GetIt.I
|
|
||||||
.get<SessionCubit>()
|
|
||||||
.state
|
|
||||||
.company!
|
|
||||||
.id!;
|
|
||||||
final deepLink =
|
|
||||||
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
final attachmentBloc = context.read<AttachmentsBloc>();
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => BlocProvider.value(
|
|
||||||
value: attachmentBloc,
|
|
||||||
child: QrUploadDialog(
|
|
||||||
deepLinkUrl: deepLink,
|
|
||||||
title: 'Carica File: ${widget.titleForUpload}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
@@ -540,12 +464,12 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (selectedFiles.length == allFiles.length) {
|
if (selectedFiles.length == allFiles.length) {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
ClearAttachmentSelectionEvent(),
|
ClearOperationFileSelectionEvent(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
SelectAllAttachmentsEvent(),
|
SelectAllOperationFilesEvent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -554,7 +478,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Loader di upload
|
// Loader di upload
|
||||||
if (state.status == AttachmentsStatus.uploading)
|
if (state.status == OperationFilesStatus.uploading)
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -570,21 +494,21 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
tooltip: 'Elimina selezionati',
|
tooltip: 'Elimina selezionati',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
DeleteAttachmentsEvent(),
|
DeleteOperationFilesEvent(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Bottone Associa a Cliente
|
// Bottone Associa a Cliente
|
||||||
if (widget.parentId != null && widget.parentId != '')
|
if (widget.currentOp.customerId != null &&
|
||||||
|
widget.currentOp.customerId!.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.person_add, color: Colors.blue),
|
icon: const Icon(Icons.person_add, color: Colors.blue),
|
||||||
tooltip: 'Copia nei documenti del Cliente',
|
tooltip: 'Copia nei documenti del Cliente',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
LinkAttachmentsToEntityEvent(
|
LinkFilesToCustomerEvent(
|
||||||
targetId: widget.parentId!,
|
customerId: widget.currentOp.customerId!,
|
||||||
targetType: AttachmentParentType.customer,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -698,8 +622,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
onTap: () => _openFile(file),
|
onTap: () => _openFile(file),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
// Selezione rapida con long press!
|
// Selezione rapida con long press!
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
ToggleAttachmentSelectionEvent(file),
|
ToggleOperationFileSelectionEvent(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -773,8 +697,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
right: 4,
|
right: 4,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<AttachmentsBloc>().add(
|
context.read<OperationFilesBloc>().add(
|
||||||
ToggleAttachmentSelectionEvent(file),
|
ToggleOperationFileSelectionEvent(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -2,29 +2,23 @@ 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/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/operations/blocs/operations_cubit.dart';
|
||||||
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
// IMPORTA IL TUO CUBIT DELLO STAFF
|
||||||
|
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
|
||||||
|
|
||||||
class StaffSection extends StatelessWidget {
|
class StaffSection extends StatelessWidget {
|
||||||
final String? label;
|
final OperationModel? currentOp;
|
||||||
final String? staffId;
|
|
||||||
final String? staffName;
|
|
||||||
final ValueChanged<StaffMemberModel> onStaffSelected;
|
|
||||||
|
|
||||||
const StaffSection({
|
const StaffSection({super.key, required this.currentOp});
|
||||||
super.key,
|
|
||||||
required this.onStaffSelected,
|
|
||||||
this.label,
|
|
||||||
this.staffId,
|
|
||||||
this.staffName,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
|
|
||||||
final selectedStaffId =
|
final selectedStaffId =
|
||||||
staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
currentOp?.staffId ??
|
||||||
|
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -32,8 +26,7 @@ class StaffSection extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12.0),
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
label ??
|
'Operatore',
|
||||||
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -41,28 +34,8 @@ class StaffSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
BlocBuilder<StaffCubit, StaffState>(
|
BlocBuilder<StaffCubit, StaffState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// FIX: Aggiunto un controllo se sta caricando
|
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
|
||||||
if (state.status == StaffStatus.loading) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 24,
|
|
||||||
width: 24,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final staffMembers = state.storeStaff;
|
final staffMembers = state.storeStaff;
|
||||||
|
|
||||||
// FIX: Feedback visivo se la lista è vuota
|
|
||||||
if (staffMembers.isEmpty) {
|
|
||||||
return const Text(
|
|
||||||
'Nessun operatore caricato. Controlla il Cubit!',
|
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final currentLoggedStaffMember = GetIt.I
|
final currentLoggedStaffMember = GetIt.I
|
||||||
.get<SessionCubit>()
|
.get<SessionCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -76,7 +49,11 @@ class StaffSection extends StatelessWidget {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onStaffSelected(staff);
|
// Aggiorniamo la form con un solo tap!
|
||||||
|
context.read<OperationsCubit>().updateOperationFields(
|
||||||
|
staffId: staff.id,
|
||||||
|
staffDisplayName: staff.name,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -1,42 +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:get_it/get_it.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class SettingsState extends Equatable {
|
|
||||||
final bool isSingleUserMode;
|
|
||||||
|
|
||||||
const SettingsState({this.isSingleUserMode = false});
|
|
||||||
|
|
||||||
SettingsState copyWith({bool? isSingleUserMode}) {
|
|
||||||
return SettingsState(
|
|
||||||
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [isSingleUserMode];
|
|
||||||
}
|
|
||||||
|
|
||||||
class SettingsCubit extends Cubit<SettingsState> {
|
|
||||||
final SharedPreferences _prefs = GetIt.I.get<SharedPreferences>();
|
|
||||||
|
|
||||||
SettingsCubit() : super(const SettingsState()) {
|
|
||||||
final bool isSingleUserMode = _prefs.getBool('isSingleUserMode') ?? false;
|
|
||||||
final sessionCubit = GetIt.I.get<SessionCubit>();
|
|
||||||
sessionCubit.setIsSingleUserMode(isSingleUserMode);
|
|
||||||
emit(state.copyWith(isSingleUserMode: isSingleUserMode));
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleSingleUserMode() {
|
|
||||||
final bool isSingleUserMode = !state.isSingleUserMode;
|
|
||||||
GetIt.I.get<SharedPreferences>().setBool(
|
|
||||||
'isSingleUserMode',
|
|
||||||
isSingleUserMode,
|
|
||||||
);
|
|
||||||
final sessionCubit = GetIt.I.get<SessionCubit>();
|
|
||||||
sessionCubit.setIsSingleUserMode(isSingleUserMode);
|
|
||||||
emit(state.copyWith(isSingleUserMode: !state.isSingleUserMode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
class DocumentSequenceState {
|
|
||||||
final List<DocumentSequence> sequences;
|
|
||||||
final bool isLoading;
|
|
||||||
final String? error;
|
|
||||||
|
|
||||||
DocumentSequenceState({
|
|
||||||
this.sequences = const [],
|
|
||||||
this.isLoading = false,
|
|
||||||
this.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class DocumentSequenceCubit extends Cubit<DocumentSequenceState> {
|
|
||||||
final String companyId;
|
|
||||||
final _supabase = Supabase.instance.client;
|
|
||||||
|
|
||||||
DocumentSequenceCubit(this.companyId) : super(DocumentSequenceState());
|
|
||||||
|
|
||||||
Future<void> loadSequences() async {
|
|
||||||
emit(DocumentSequenceState(isLoading: true));
|
|
||||||
try {
|
|
||||||
final data = await _supabase
|
|
||||||
.from('document_sequences')
|
|
||||||
.select()
|
|
||||||
.eq('company_id', companyId);
|
|
||||||
|
|
||||||
final list = (data as List)
|
|
||||||
.map((e) => DocumentSequence.fromMap(e))
|
|
||||||
.toList();
|
|
||||||
emit(DocumentSequenceState(sequences: list));
|
|
||||||
} catch (e) {
|
|
||||||
emit(DocumentSequenceState(error: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateLocalSequence(String docType, {String? prefix, int? nextValue}) {
|
|
||||||
final newList = state.sequences.map((s) {
|
|
||||||
if (s.docType == docType) {
|
|
||||||
return s.copyWith(prefix: prefix, nextValue: nextValue);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}).toList();
|
|
||||||
emit(DocumentSequenceState(sequences: newList));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveSequences() async {
|
|
||||||
try {
|
|
||||||
for (var seq in state.sequences) {
|
|
||||||
await _supabase.from('document_sequences').upsert({
|
|
||||||
'company_id': companyId,
|
|
||||||
'doc_type': seq.docType,
|
|
||||||
'next_value': seq.nextValue,
|
|
||||||
'prefix': seq.prefix,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Opzionale: mostra un feedback di successo
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
DocumentSequenceState(
|
|
||||||
sequences: state.sequences,
|
|
||||||
error: "Errore nel salvataggio",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
class DocumentSequenceRepository {
|
|
||||||
final _supabase = GetIt.I.get<SupabaseClient>();
|
|
||||||
|
|
||||||
Future<List<DocumentSequence>> getDocumentSequences(String companyId) async {
|
|
||||||
final response = await _supabase
|
|
||||||
.from('document_sequences')
|
|
||||||
.select()
|
|
||||||
.eq('company_id', companyId);
|
|
||||||
|
|
||||||
return (response as List).map((e) => DocumentSequence.fromMap(e)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateSequence({
|
|
||||||
required String companyId,
|
|
||||||
required String docType,
|
|
||||||
required int nextValue,
|
|
||||||
required String prefix,
|
|
||||||
}) async {
|
|
||||||
await _supabase.from('document_sequences').upsert({
|
|
||||||
'company_id': companyId,
|
|
||||||
'doc_type': docType,
|
|
||||||
'next_value': nextValue,
|
|
||||||
'prefix': prefix,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
enum DocumentType { ticket, ddt, invoice }
|
|
||||||
|
|
||||||
class DocumentSequence {
|
|
||||||
final String docType;
|
|
||||||
final int nextValue;
|
|
||||||
final String prefix;
|
|
||||||
|
|
||||||
DocumentSequence({
|
|
||||||
required this.docType,
|
|
||||||
required this.nextValue,
|
|
||||||
required this.prefix,
|
|
||||||
});
|
|
||||||
|
|
||||||
DocumentSequence copyWith({int? nextValue, String? prefix}) {
|
|
||||||
return DocumentSequence(
|
|
||||||
docType: docType,
|
|
||||||
nextValue: nextValue ?? this.nextValue,
|
|
||||||
prefix: prefix ?? this.prefix,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory DocumentSequence.fromMap(Map<String, dynamic> map) {
|
|
||||||
return DocumentSequence(
|
|
||||||
docType: map['doc_type'],
|
|
||||||
nextValue: map['next_value'],
|
|
||||||
prefix: map['prefix'] ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
|
|
||||||
|
|
||||||
class DocumentSequenceSection extends StatelessWidget {
|
|
||||||
const DocumentSequenceSection({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final year = DateTime.now().year;
|
|
||||||
|
|
||||||
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
|
||||||
child: Text(
|
|
||||||
"Protocolli e Numerazione",
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...state.sequences.map((seq) {
|
|
||||||
// Anteprima dinamica
|
|
||||||
final preview =
|
|
||||||
"${seq.prefix.isNotEmpty ? '${seq.prefix}-' : ''}$year-${seq.nextValue.toString().padLeft(6, '0')}";
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
seq.docType.toUpperCase(),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: TextFormField(
|
|
||||||
initialValue: seq.prefix,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prefisso',
|
|
||||||
hintText: 'es. TCK',
|
|
||||||
),
|
|
||||||
onChanged: (val) => context
|
|
||||||
.read<DocumentSequenceCubit>()
|
|
||||||
.updateLocalSequence(
|
|
||||||
seq.docType,
|
|
||||||
prefix: val,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: TextFormField(
|
|
||||||
initialValue: seq.nextValue.toString(),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prossimo Numero',
|
|
||||||
),
|
|
||||||
onChanged: (val) => context
|
|
||||||
.read<DocumentSequenceCubit>()
|
|
||||||
.updateLocalSequence(
|
|
||||||
seq.docType,
|
|
||||||
nextValue: int.tryParse(val) ?? 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.visibility,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
"Anteprima prossimo: ",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
preview,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
context.read<DocumentSequenceCubit>().saveSequences(),
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text("SALVA PROTOCOLLI"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
// lib/ui/impostazioni/impostazioni_view.dart
|
// lib/ui/impostazioni/impostazioni_view.dart
|
||||||
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/routes/routes.dart';
|
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/settings/blocs/settings_cubit.dart';
|
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsView extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -19,41 +15,48 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_settingsSection('Account', [
|
_settingsSection('Account', [
|
||||||
_settingsTile(
|
_settingsTile(
|
||||||
icon: Icons.person,
|
Icons.person,
|
||||||
title: 'Profilo Utente',
|
'Profilo Utente',
|
||||||
subtitle: 'Configura i tuoi dati',
|
'Configura i tuoi dati',
|
||||||
context: context,
|
context,
|
||||||
onTap: () {},
|
MaterialPageRoute(
|
||||||
),
|
builder: (context) => const ThemeSettingsView(),
|
||||||
BlocBuilder<SettingsCubit, SettingsState>(
|
|
||||||
builder: (context, state) => CheckboxListTile(
|
|
||||||
value: state.isSingleUserMode,
|
|
||||||
title: const Text('Singolo Utente'),
|
|
||||||
onChanged: (_) =>
|
|
||||||
context.read<SettingsCubit>().toggleSingleUserMode(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_settingsTile(
|
_settingsTile(
|
||||||
title: 'Impostazioni Azienda',
|
Icons.store,
|
||||||
icon: Icons.business,
|
'Mio Negozio',
|
||||||
subtitle: 'Configura i dati aziendali',
|
'Piacenza Centro',
|
||||||
context: context,
|
context,
|
||||||
onTap: () => context.pushNamed(Routes.companySettings),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ThemeSettingsView(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_settingsSection('Applicazione', [
|
_settingsSection('Applicazione', [
|
||||||
_settingsTile(
|
_settingsTile(
|
||||||
icon: Icons.dark_mode,
|
Icons.sync,
|
||||||
title: 'Tema (FLUX Dark)',
|
'Sincronizzazione',
|
||||||
subtitle: 'Configurazione visiva',
|
'Ultima: 5 min fa',
|
||||||
context: context,
|
context,
|
||||||
onTap: () => context.pushNamed(Routes.themeSettings),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ThemeSettingsView(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_settingsTile(
|
||||||
|
Icons.dark_mode,
|
||||||
|
'Tema (FLUX Dark)',
|
||||||
|
'Configurazione visiva',
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ThemeSettingsView(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => context.read<SessionCubit>().signOut(),
|
onPressed: () {},
|
||||||
icon: const Icon(Icons.exit_to_app, color: Colors.red),
|
icon: const Icon(Icons.exit_to_app, color: Colors.red),
|
||||||
label: const Text('Logout', style: TextStyle(color: Colors.red)),
|
label: const Text('Logout', style: TextStyle(color: Colors.red)),
|
||||||
),
|
),
|
||||||
@@ -80,22 +83,22 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _settingsTile({
|
Widget _settingsTile(
|
||||||
required BuildContext context,
|
IconData icon,
|
||||||
required IconData icon,
|
String title,
|
||||||
required String title,
|
String subtitle,
|
||||||
String? subtitle,
|
BuildContext context,
|
||||||
required VoidCallback onTap,
|
MaterialPageRoute route,
|
||||||
}) {
|
) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(icon, color: FluxColors.primaryBlue),
|
leading: Icon(icon, color: FluxColors.primaryBlue),
|
||||||
title: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
title: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
subtitle: Text(subtitle ?? ''),
|
subtitle: Text(subtitle),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
),
|
),
|
||||||
onTap: onTap,
|
onTap: () => Navigator.of(context).push(route),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
|
||||||
import 'package:flux/features/tracking/data/tracking_repository.dart';
|
|
||||||
import 'package:flux/features/tracking/models/tracking_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'ticket_form_state.dart';
|
|
||||||
|
|
||||||
class TicketFormCubit extends Cubit<TicketFormState> {
|
|
||||||
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
|
|
||||||
final SessionCubit _sessionCubit = GetIt.I.get<SessionCubit>();
|
|
||||||
|
|
||||||
// Costruttore: prepariamo subito il ticket base con i dati di chi lo crea
|
|
||||||
TicketFormCubit({StaffMemberModel? createdBy, TicketModel? existingTicket})
|
|
||||||
: super(
|
|
||||||
TicketFormState(
|
|
||||||
// Se c'è un ticket esistente usa quello, ALTRIMENTI ne crea uno vuoto
|
|
||||||
// e ci stampa subito il nome del creatore!
|
|
||||||
ticket:
|
|
||||||
existingTicket ??
|
|
||||||
TicketModel.empty().copyWith(
|
|
||||||
createdById: createdBy?.id,
|
|
||||||
createdByName: createdBy?.name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 1. INIZIALIZZAZIONE
|
|
||||||
Future<void> initForm({String? id, TicketModel? existingTicket}) async {
|
|
||||||
if (existingTicket != null) {
|
|
||||||
// SCENARIO 1: Abbiamo il ticket intero passato via record
|
|
||||||
emit(
|
|
||||||
state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready),
|
|
||||||
);
|
|
||||||
} else if (id != null) {
|
|
||||||
// SCENARIO 2: QR CODE o Web Refresh! (Hai solo l'ID)
|
|
||||||
emit(state.copyWith(status: TicketFormStatus.loading));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Boom! Lo scarica dal database in tempo reale
|
|
||||||
final fetchedTicket = await _repository.getTicketById(id);
|
|
||||||
emit(
|
|
||||||
state.copyWith(ticket: fetchedTicket, status: TicketFormStatus.ready),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: TicketFormStatus.failure,
|
|
||||||
errorMessage: 'Ticket non trovato',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// SCENARIO 3: Nuovo Ticket
|
|
||||||
final currentStore = _sessionCubit.state.currentStore;
|
|
||||||
final companyId = _sessionCubit.state.company?.id ?? '';
|
|
||||||
|
|
||||||
// IL TRUCCO È QUI: Usiamo `state.ticket` invece di `TicketModel.empty()`.
|
|
||||||
// `state.ticket` HA GIÀ i dati di 'createdBy' settati nel costruttore!
|
|
||||||
final newTicket = state.ticket.copyWith(
|
|
||||||
companyId: companyId,
|
|
||||||
storeId: currentStore?.id,
|
|
||||||
ticketStatus: TicketStatus.open, // <-- O il tuo status di default
|
|
||||||
ticketType: TicketType.repair,
|
|
||||||
);
|
|
||||||
|
|
||||||
emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 2. AGGIORNAMENTO CLIENTE (Usato dal nostro SharedCustomerSection!)
|
|
||||||
void updateCustomer(CustomerModel customer) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
ticket: state.ticket.copyWith(
|
|
||||||
customerId: customer.id,
|
|
||||||
customer: customer,
|
|
||||||
alternativePhoneNumber:
|
|
||||||
state.ticket.alternativePhoneNumber ?? customer.phoneNumber,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 3. AGGIORNAMENTO MODELLO (Usato dal nostro SharedModelSection!)
|
|
||||||
void updateTargetModel({required String modelId, required String modelName}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
ticket: state.ticket.copyWith(
|
|
||||||
targetModelId: modelId,
|
|
||||||
targetModelName: modelName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateSourceModel({required String modelId, required String modelName}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
ticket: state.ticket.copyWith(
|
|
||||||
sourceModelId: modelId,
|
|
||||||
sourceModelName: modelName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateCreator({required String staffId, required String staffName}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
ticket: state.ticket.copyWith(
|
|
||||||
createdById: staffId,
|
|
||||||
createdByName: staffName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 4. AGGIORNAMENTO GENERICO DEI CAMPI
|
|
||||||
void updateFields({
|
|
||||||
TicketType? ticketType,
|
|
||||||
TicketStatus? status,
|
|
||||||
String? request,
|
|
||||||
String? targetSn,
|
|
||||||
String? sourceSn,
|
|
||||||
String? alternativePhoneNumber,
|
|
||||||
bool? hasCourtesyDevice,
|
|
||||||
String? includedAccessories,
|
|
||||||
String? publicNotes,
|
|
||||||
String? internalNotes,
|
|
||||||
double? customerPrice,
|
|
||||||
double? internalCost,
|
|
||||||
String? assignedToId,
|
|
||||||
String? assignedToName,
|
|
||||||
}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
ticket: state.ticket.copyWith(
|
|
||||||
ticketType: ticketType ?? state.ticket.ticketType,
|
|
||||||
ticketStatus: status ?? state.ticket.ticketStatus,
|
|
||||||
request: request ?? state.ticket.request,
|
|
||||||
targetSn: targetSn ?? state.ticket.targetSn,
|
|
||||||
sourceSn: sourceSn ?? state.ticket.sourceSn,
|
|
||||||
alternativePhoneNumber:
|
|
||||||
alternativePhoneNumber ?? state.ticket.alternativePhoneNumber,
|
|
||||||
hasCourtesyDevice:
|
|
||||||
hasCourtesyDevice ?? state.ticket.hasCourtesyDevice,
|
|
||||||
includedAccessories:
|
|
||||||
includedAccessories ?? state.ticket.includedAccessories,
|
|
||||||
publicNotes: publicNotes ?? state.ticket.publicNotes,
|
|
||||||
internalNotes: internalNotes ?? state.ticket.internalNotes,
|
|
||||||
customerPrice: customerPrice ?? state.ticket.customerPrice,
|
|
||||||
internalCost: internalCost ?? state.ticket.internalCost,
|
|
||||||
assignedToId: assignedToId ?? state.ticket.assignedToId,
|
|
||||||
assignedToName: assignedToName ?? state.ticket.assignedToName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 5. SALVATAGGIO
|
|
||||||
Future<void> saveTicket() async {
|
|
||||||
emit(state.copyWith(status: TicketFormStatus.saving));
|
|
||||||
|
|
||||||
try {
|
|
||||||
final ticketToSave = state.ticket;
|
|
||||||
|
|
||||||
// Validazione base
|
|
||||||
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
|
|
||||||
throw Exception("Seleziona un cliente prima di salvare.");
|
|
||||||
}
|
|
||||||
TicketModel? savedTicket;
|
|
||||||
if (ticketToSave.id == null) {
|
|
||||||
savedTicket = await _repository.insertTicket(ticketToSave);
|
|
||||||
} else {
|
|
||||||
savedTicket = await _repository.updateTicket(ticketToSave);
|
|
||||||
}
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: TicketFormStatus.success,
|
|
||||||
ticket: ticketToSave.copyWith(
|
|
||||||
id: savedTicket.id,
|
|
||||||
referenceId: savedTicket.referenceId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: TicketFormStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 5.1 SALVATAGGIO SILENZIOSO (Per generare il QR Code al volo)
|
|
||||||
Future<String?> saveTicketDraft() async {
|
|
||||||
// Non mettiamo lo stato 'saving' per non far sfarfallare tutta la UI,
|
|
||||||
// usiamo un caricamento invisibile.
|
|
||||||
try {
|
|
||||||
final ticketToSave = state.ticket;
|
|
||||||
|
|
||||||
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
|
|
||||||
throw Exception("Seleziona un cliente prima di poter usare il QR.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final savedTicket = await _repository.insertTicket(ticketToSave);
|
|
||||||
|
|
||||||
// Aggiorniamo silenziosamente lo stato con il ticket che ora ha un ID!
|
|
||||||
emit(state.copyWith(ticket: savedTicket, status: TicketFormStatus.ready));
|
|
||||||
|
|
||||||
return savedTicket.id;
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: TicketFormStatus.failure,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> takeInCharge({
|
|
||||||
required String staffId,
|
|
||||||
required String staffName,
|
|
||||||
}) async {
|
|
||||||
final currentTicket = state.ticket;
|
|
||||||
|
|
||||||
// Sicurezza: non possiamo prendere in carico un ticket fantasma
|
|
||||||
if (currentTicket.id == null || currentTicket.id!.isEmpty) return;
|
|
||||||
|
|
||||||
// 1. Prepariamo il ticket aggiornato
|
|
||||||
final updatedTicket = currentTicket.copyWith(
|
|
||||||
ticketStatus: TicketStatus
|
|
||||||
.inProgress, // Assumendo che tu abbia un enum per gli stati
|
|
||||||
assignedToId: staffId,
|
|
||||||
assignedToName: staffName,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. Aggiorniamo il ticket sul Database (usa il tuo metodo esistente del repo)
|
|
||||||
await _repository.updateTicket(updatedTicket);
|
|
||||||
|
|
||||||
// 3. Spara il log automatico nella Timeline!
|
|
||||||
await GetIt.I.get<TrackingRepository>().logQuickEvent(
|
|
||||||
companyId: currentTicket.companyId,
|
|
||||||
message: "Ticket preso in carico. Inizio lavorazione.",
|
|
||||||
type: TrackingType.statusChange,
|
|
||||||
parentId: currentTicket.id!,
|
|
||||||
parentType: TrackingParentType.ticket,
|
|
||||||
staffId: staffId,
|
|
||||||
// Lo mettiamo pubblico (isInternal: false) così il cliente a casa vede che
|
|
||||||
// il suo dispositivo è ufficialmente sotto i ferri!
|
|
||||||
isInternal: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Aggiorniamo lo stato locale del Cubit per far scattare la UI
|
|
||||||
emit(state.copyWith(ticket: updatedTicket));
|
|
||||||
} catch (e) {
|
|
||||||
// Gestisci eventuali errori (es. mostrando una snackbar)
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: TicketFormStatus.failure,
|
|
||||||
errorMessage: 'Errore durante la presa in carico: $e',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
// Adatta gli import al tuo progetto!
|
|
||||||
|
|
||||||
enum TicketFormStatus { initial, ready, loading, saving, success, pop, failure }
|
|
||||||
|
|
||||||
class TicketFormState extends Equatable {
|
|
||||||
final TicketModel ticket;
|
|
||||||
final TicketFormStatus status;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
const TicketFormState({
|
|
||||||
required this.ticket,
|
|
||||||
this.status = TicketFormStatus.initial,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [ticket, status, errorMessage];
|
|
||||||
|
|
||||||
TicketFormState copyWith({
|
|
||||||
TicketModel? ticket,
|
|
||||||
TicketFormStatus? status,
|
|
||||||
String? errorMessage,
|
|
||||||
}) {
|
|
||||||
return TicketFormState(
|
|
||||||
ticket: ticket ?? this.ticket,
|
|
||||||
status: status ?? this.status,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'ticket_list_state.dart';
|
|
||||||
|
|
||||||
class TicketListCubit extends Cubit<TicketListState> {
|
|
||||||
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
|
|
||||||
static const int _limit = 20; // Paginazione a blocchi di 20
|
|
||||||
|
|
||||||
TicketListCubit() : super(const TicketListState()) {
|
|
||||||
fetchTickets(reset: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0.
|
|
||||||
Future<void> fetchTickets({bool reset = false}) async {
|
|
||||||
if (state.isLoading) return;
|
|
||||||
if (!reset && state.hasReachedMax) return;
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
isLoading: true,
|
|
||||||
errorMessage: '',
|
|
||||||
tickets: reset ? [] : state.tickets,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final currentOffset = reset ? 0 : state.tickets.length;
|
|
||||||
|
|
||||||
final newTickets = await _repository.fetchStoreTickets(
|
|
||||||
offset: currentOffset,
|
|
||||||
limit: _limit,
|
|
||||||
searchTerm: state.searchTerm,
|
|
||||||
dateRange: state.dateRange,
|
|
||||||
statusFilter: state.statusFilter,
|
|
||||||
ticketTypeFilter: state.ticketTypeFilter,
|
|
||||||
staffIdFilter: state.staffIdFilter,
|
|
||||||
);
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
tickets: reset ? newTickets : [...state.tickets, ...newTickets],
|
|
||||||
isLoading: false,
|
|
||||||
hasReachedMax: newTickets.length < _limit,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggiorna i filtri e ricarica tutto da zero
|
|
||||||
void updateFilters({
|
|
||||||
String? searchTerm,
|
|
||||||
DateTimeRange? dateRange,
|
|
||||||
TicketStatus? statusFilter,
|
|
||||||
TicketType? ticketTypeFilter,
|
|
||||||
String? staffIdFilter,
|
|
||||||
bool clearSearch = false,
|
|
||||||
bool clearDate = false,
|
|
||||||
bool clearStatus = false,
|
|
||||||
}) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
dateRange: dateRange,
|
|
||||||
statusFilter: statusFilter,
|
|
||||||
ticketTypeFilter: ticketTypeFilter,
|
|
||||||
staffIdFilter: staffIdFilter,
|
|
||||||
clearSearch: clearSearch,
|
|
||||||
clearDate: clearDate,
|
|
||||||
clearStatus: clearStatus,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fetchTickets(reset: true); // Applica i filtri e ricarica
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
|
|
||||||
class TicketListState extends Equatable {
|
|
||||||
final List<TicketModel> tickets;
|
|
||||||
final bool isLoading;
|
|
||||||
final bool hasReachedMax;
|
|
||||||
final String errorMessage;
|
|
||||||
|
|
||||||
// Filtri attivi
|
|
||||||
final String? searchTerm;
|
|
||||||
final DateTimeRange? dateRange;
|
|
||||||
final TicketStatus? statusFilter;
|
|
||||||
final TicketType? ticketTypeFilter;
|
|
||||||
final String? staffIdFilter;
|
|
||||||
|
|
||||||
const TicketListState({
|
|
||||||
this.tickets = const [],
|
|
||||||
this.isLoading = false,
|
|
||||||
this.hasReachedMax = false,
|
|
||||||
this.errorMessage = '',
|
|
||||||
this.searchTerm,
|
|
||||||
this.dateRange,
|
|
||||||
this.statusFilter,
|
|
||||||
this.ticketTypeFilter,
|
|
||||||
this.staffIdFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
TicketListState copyWith({
|
|
||||||
List<TicketModel>? tickets,
|
|
||||||
bool? isLoading,
|
|
||||||
bool? hasReachedMax,
|
|
||||||
String? errorMessage,
|
|
||||||
String? searchTerm,
|
|
||||||
DateTimeRange? dateRange,
|
|
||||||
TicketStatus? statusFilter,
|
|
||||||
TicketType? ticketTypeFilter,
|
|
||||||
String? staffIdFilter,
|
|
||||||
bool clearSearch = false,
|
|
||||||
bool clearDate = false,
|
|
||||||
bool clearStatus = false,
|
|
||||||
}) {
|
|
||||||
return TicketListState(
|
|
||||||
tickets: tickets ?? this.tickets,
|
|
||||||
isLoading: isLoading ?? this.isLoading,
|
|
||||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
|
||||||
searchTerm: clearSearch ? null : (searchTerm ?? this.searchTerm),
|
|
||||||
dateRange: clearDate ? null : (dateRange ?? this.dateRange),
|
|
||||||
statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter),
|
|
||||||
ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter,
|
|
||||||
staffIdFilter: staffIdFilter ?? this.staffIdFilter,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
tickets,
|
|
||||||
isLoading,
|
|
||||||
hasReachedMax,
|
|
||||||
errorMessage,
|
|
||||||
searchTerm,
|
|
||||||
dateRange,
|
|
||||||
statusFilter,
|
|
||||||
ticketTypeFilter,
|
|
||||||
staffIdFilter,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
class TicketRepository {
|
|
||||||
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
|
||||||
|
|
||||||
TicketRepository();
|
|
||||||
|
|
||||||
static const String _tableName = 'ticket';
|
|
||||||
|
|
||||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI UNO STORE ---
|
|
||||||
Future<List<TicketModel>> fetchStoreTickets({
|
|
||||||
required int offset,
|
|
||||||
int limit = 50,
|
|
||||||
String? searchTerm,
|
|
||||||
DateTimeRange? dateRange,
|
|
||||||
TicketStatus? statusFilter,
|
|
||||||
TicketType? ticketTypeFilter,
|
|
||||||
String? staffIdFilter,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
var query = _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.select('''
|
|
||||||
*,
|
|
||||||
customer (*),
|
|
||||||
created_by:staff_member!ticket_staff_id_fkey (*),
|
|
||||||
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
|
|
||||||
target_model:model!ticket_model_id_1_fkey (*),
|
|
||||||
source_model:model!ticket_model_id_2_fkey (*)
|
|
||||||
''')
|
|
||||||
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!);
|
|
||||||
|
|
||||||
// Filtro Range Date
|
|
||||||
if (dateRange != null) {
|
|
||||||
query = query
|
|
||||||
.gte('created_at', dateRange.start.toIso8601String())
|
|
||||||
.lte('created_at', dateRange.end.toIso8601String());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusFilter != null) {
|
|
||||||
query = query.eq('status', statusFilter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketTypeFilter != null) {
|
|
||||||
query = query.eq('ticket_type', ticketTypeFilter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staffIdFilter != null) {
|
|
||||||
query = query.eq('staff_id', staffIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
|
||||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
|
||||||
query = query.or('customer.name.ilike.%$searchTerm%');
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await query
|
|
||||||
.order('created_at', ascending: false)
|
|
||||||
.range(offset, offset + limit - 1);
|
|
||||||
|
|
||||||
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('$e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
|
|
||||||
Future<List<TicketModel>> fetchCompanyTickets({
|
|
||||||
required int offset,
|
|
||||||
int limit = 50,
|
|
||||||
String? searchTerm,
|
|
||||||
DateTimeRange? dateRange,
|
|
||||||
TicketStatus? ticketStatusFilter,
|
|
||||||
TicketType? ticketTypeFilter,
|
|
||||||
String? staffIdFilter,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
var query = _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.select('''
|
|
||||||
*,
|
|
||||||
customer (*),
|
|
||||||
created_by:staff_member!ticket_staff_id_fkey (*),
|
|
||||||
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
|
|
||||||
target_model:model!ticket_model_id_1_fkey (*),
|
|
||||||
source_model:model!ticket_model_id_2_fkey (*)
|
|
||||||
''')
|
|
||||||
.eq('company_id', GetIt.I.get<SessionCubit>().state.company!.id!);
|
|
||||||
|
|
||||||
// Filtro Range Date
|
|
||||||
if (dateRange != null) {
|
|
||||||
query = query
|
|
||||||
.gte('created_at', dateRange.start.toIso8601String())
|
|
||||||
.lte('created_at', dateRange.end.toIso8601String());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketStatusFilter != null) {
|
|
||||||
query = query.eq('status', ticketStatusFilter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketTypeFilter != null) {
|
|
||||||
query = query.eq('ticket_type', ticketTypeFilter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staffIdFilter != null) {
|
|
||||||
query = query.eq('staff_id', staffIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
|
||||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
|
||||||
query = query.or('customer.name.ilike.%$searchTerm%');
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await query
|
|
||||||
.order('created_at', ascending: false)
|
|
||||||
.range(offset, offset + limit - 1);
|
|
||||||
|
|
||||||
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('$e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream dei ticket che necessitano attenzione (es. in scadenza oggi o in ritardo)
|
|
||||||
Stream<List<TicketModel>> getAttentionNeededTicketsStream() {
|
|
||||||
return _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.stream(primaryKey: ['id'])
|
|
||||||
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!)
|
|
||||||
// Purtroppo lo stream accetta solo filtri base, quindi ci facciamo
|
|
||||||
// mandare i dati e li filtriamo con la potenza di Dart!
|
|
||||||
.limit(300)
|
|
||||||
.map((listOfMaps) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final endOfToday = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
|
||||||
|
|
||||||
// 1. Mappiamo tutto in TicketModel
|
|
||||||
final allStoreTickets = listOfMaps
|
|
||||||
.map((map) => TicketModel.fromMap(map))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 2. Filtriamo in memoria!
|
|
||||||
final urgentTickets = allStoreTickets.where((ticket) {
|
|
||||||
// Escludiamo quelli già chiusi o consegnati
|
|
||||||
if (ticket.ticketStatus == TicketStatus.closed ||
|
|
||||||
ticket.ticketStatus == TicketStatus.ready) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se c'è una data di consegna stimata ed è <= a stasera, è urgente!
|
|
||||||
if (ticket.estimatedDeliveryAt != null) {
|
|
||||||
return ticket.estimatedDeliveryAt!.isBefore(endOfToday);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// 3. Li ordiniamo mettendo i più vecchi/urgenti in cima
|
|
||||||
urgentTickets.sort(
|
|
||||||
(a, b) => a.estimatedDeliveryAt!.compareTo(b.estimatedDeliveryAt!),
|
|
||||||
);
|
|
||||||
|
|
||||||
return urgentTickets;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli)
|
|
||||||
/// Questa è la vera magia di Supabase!
|
|
||||||
Future<TicketModel> getTicketById(String ticketId) async {
|
|
||||||
try {
|
|
||||||
// Usiamo i nomi esatti delle Foreign Key che hai definito nell'SQL!
|
|
||||||
final response = await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.select('''
|
|
||||||
*,
|
|
||||||
customer (*),
|
|
||||||
target_model:model!ticket_model_id_1_fkey (*),
|
|
||||||
source_model:model!ticket_model_id_2_fkey (*),
|
|
||||||
created_by:staff_member!ticket_staff_id_fkey (*),
|
|
||||||
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
|
|
||||||
''')
|
|
||||||
.eq('id', ticketId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return TicketModel.fromMap(response);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore nel recupero del dettaglio ticket: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> generateTicketReference(String companyId) async {
|
|
||||||
final response = await Supabase.instance.client.rpc(
|
|
||||||
'get_next_document_number',
|
|
||||||
params: {'p_company_id': companyId, 'p_doc_type': 'ticket'},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Estraiamo i dati dal JSON
|
|
||||||
final int nextValue = response['next_value'];
|
|
||||||
final String prefix = response['prefix'] ?? '';
|
|
||||||
|
|
||||||
final year = DateTime.now().year; // 2026
|
|
||||||
|
|
||||||
// Formattazione con zeri iniziali (es. 000125)
|
|
||||||
final paddedNumber = nextValue.toString().padLeft(6, '0');
|
|
||||||
|
|
||||||
// Costruiamo la stringa. Se c'è un prefisso mette "TCK-2026-000125",
|
|
||||||
// altrimenti solo "2026-000125"
|
|
||||||
if (prefix.isNotEmpty) {
|
|
||||||
return '$prefix-$year-$paddedNumber';
|
|
||||||
} else {
|
|
||||||
return '$year-$paddedNumber';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Salva il ticket
|
|
||||||
Future<TicketModel> insertTicket(TicketModel ticket) async {
|
|
||||||
if (ticket.id != null) {
|
|
||||||
throw Exception('Impossibile creare un ticket esistente, id not null');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final ticketToSave = ticket.copyWith(
|
|
||||||
referenceId: await generateTicketReference(ticket.companyId),
|
|
||||||
);
|
|
||||||
final response = await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.insert(ticketToSave.toMap())
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return TicketModel.fromMap(response);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore nella creazione del ticket: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggiorna un ticket esistente
|
|
||||||
Future<TicketModel> updateTicket(TicketModel ticket) async {
|
|
||||||
if (ticket.id == null) {
|
|
||||||
throw Exception('Impossibile aggiornare un ticket senza ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await _supabase
|
|
||||||
.from(_tableName)
|
|
||||||
.update(ticket.toMap())
|
|
||||||
.eq('id', ticket.id!)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return TicketModel.fromMap(response);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore nell\'aggiornamento del ticket: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Elimina (o annulla) un ticket
|
|
||||||
Future<void> deleteTicket(String ticketId) async {
|
|
||||||
try {
|
|
||||||
await _supabase.from(_tableName).delete().eq('id', ticketId);
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Errore nell\'eliminazione del ticket: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
|
||||||
|
|
||||||
/// Enum per il tipo di ticket
|
|
||||||
enum TicketType {
|
|
||||||
repair('repair', 'Riparazione'),
|
|
||||||
softwareSetup('software_setup', 'Impost. software'),
|
|
||||||
dataTransfer('data_transfer', 'Trasf. dati'),
|
|
||||||
operationTicket('operation_ticket', 'Ticket operazione'),
|
|
||||||
other('other', 'Altro');
|
|
||||||
|
|
||||||
final String value;
|
|
||||||
final String displayValue;
|
|
||||||
const TicketType(this.value, this.displayValue);
|
|
||||||
|
|
||||||
static TicketType fromString(String val) {
|
|
||||||
return TicketType.values.firstWhere(
|
|
||||||
(e) => e.value == val,
|
|
||||||
orElse: () => TicketType.other,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enum per lo stato del ticket
|
|
||||||
enum TicketStatus {
|
|
||||||
open('open', 'Aperto'),
|
|
||||||
inProgress('in_progress', 'In corso'),
|
|
||||||
waitingForParts('waiting_for_parts', 'In attesa di ricambi'),
|
|
||||||
ready('ready', 'Pronto'),
|
|
||||||
closed('closed', 'Chiuso'),
|
|
||||||
waitingForShipping('waiting_for_shipping', 'In attesa di spedire'),
|
|
||||||
waitingForReturn('waiting_for_return', 'In attesa di ritorno');
|
|
||||||
|
|
||||||
final String value;
|
|
||||||
final String displayValue;
|
|
||||||
const TicketStatus(this.value, this.displayValue);
|
|
||||||
|
|
||||||
static TicketStatus fromString(String? val) {
|
|
||||||
return TicketStatus.values.firstWhere(
|
|
||||||
(e) => e.value == val,
|
|
||||||
orElse: () => TicketStatus.open,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enum per il risultato del ticket (OK / KO)
|
|
||||||
enum TicketResult {
|
|
||||||
success('success', 'Risolto (OK)'),
|
|
||||||
failure('failure', 'Non Risolto (KO)');
|
|
||||||
|
|
||||||
final String value;
|
|
||||||
final String displayValue;
|
|
||||||
const TicketResult(this.value, this.displayValue);
|
|
||||||
|
|
||||||
static TicketResult? fromString(String? val) {
|
|
||||||
if (val == null) return null;
|
|
||||||
return TicketResult.values.firstWhere(
|
|
||||||
(e) => e.value == val,
|
|
||||||
orElse: () => TicketResult.success,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enum per il tipo di garanzia
|
|
||||||
enum WarrantyType {
|
|
||||||
manufacturerWarranty('manufacturer_warranty', 'Garanzia produttore'),
|
|
||||||
providerWarranty('provider_warranty', 'Garanzia gestore'),
|
|
||||||
internalWarranty('internal_warranty', 'Garanzia interna'),
|
|
||||||
noWarranty('no_warranty', 'Fuori garanzia');
|
|
||||||
|
|
||||||
final String value;
|
|
||||||
final String displayValue;
|
|
||||||
const WarrantyType(this.value, this.displayValue);
|
|
||||||
|
|
||||||
static WarrantyType? fromString(String? val) {
|
|
||||||
return WarrantyType.values.firstWhere(
|
|
||||||
(e) => e.value == val,
|
|
||||||
orElse: () => WarrantyType.noWarranty,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TicketModel extends Equatable {
|
|
||||||
final String? id; // Null se non ancora salvato
|
|
||||||
final DateTime? createdAt;
|
|
||||||
final String companyId;
|
|
||||||
final String? storeId;
|
|
||||||
final String? customerId;
|
|
||||||
final String? targetModelId;
|
|
||||||
final String? targetSn;
|
|
||||||
final String? sourceModelId;
|
|
||||||
final String? sourceSn;
|
|
||||||
final double customerPrice;
|
|
||||||
final double internalCost;
|
|
||||||
final DateTime? closedAt;
|
|
||||||
final DateTime? returnedAt;
|
|
||||||
final String request;
|
|
||||||
final WarrantyType? warrantyType;
|
|
||||||
final String? publicNotes;
|
|
||||||
final String? internalNotes;
|
|
||||||
final String? referenceId;
|
|
||||||
final String? alternativePhoneNumber;
|
|
||||||
final bool hasCourtesyDevice;
|
|
||||||
final TicketType ticketType;
|
|
||||||
final TicketStatus ticketStatus;
|
|
||||||
final DateTime? estimatedDeliveryAt;
|
|
||||||
final TicketResult? ticketResult;
|
|
||||||
final String? resolutionNotes;
|
|
||||||
final CustomerModel? customer;
|
|
||||||
final String? targetModelName;
|
|
||||||
final String? sourceModelName;
|
|
||||||
final String? createdById;
|
|
||||||
final String? createdByName;
|
|
||||||
final String? assignedToId;
|
|
||||||
final String? assignedToName;
|
|
||||||
final String? includedAccessories;
|
|
||||||
|
|
||||||
const TicketModel({
|
|
||||||
this.id,
|
|
||||||
this.createdAt,
|
|
||||||
required this.companyId,
|
|
||||||
this.storeId,
|
|
||||||
this.customerId,
|
|
||||||
this.targetModelId,
|
|
||||||
this.targetSn,
|
|
||||||
this.sourceModelId,
|
|
||||||
this.sourceSn,
|
|
||||||
this.customerPrice = 0.0,
|
|
||||||
this.internalCost = 0.0,
|
|
||||||
this.closedAt,
|
|
||||||
this.returnedAt,
|
|
||||||
this.request = '',
|
|
||||||
this.warrantyType,
|
|
||||||
this.publicNotes,
|
|
||||||
this.internalNotes,
|
|
||||||
this.referenceId,
|
|
||||||
this.alternativePhoneNumber,
|
|
||||||
this.hasCourtesyDevice = false,
|
|
||||||
required this.ticketType,
|
|
||||||
this.ticketStatus = TicketStatus.closed,
|
|
||||||
this.estimatedDeliveryAt,
|
|
||||||
this.ticketResult,
|
|
||||||
this.resolutionNotes,
|
|
||||||
this.customer,
|
|
||||||
this.targetModelName,
|
|
||||||
this.sourceModelName,
|
|
||||||
this.createdById,
|
|
||||||
this.createdByName,
|
|
||||||
this.assignedToId,
|
|
||||||
this.assignedToName,
|
|
||||||
this.includedAccessories,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Factory per creare un ticket vuoto (utile per i form di creazione)
|
|
||||||
factory TicketModel.empty({String? companyId, String? storeId}) {
|
|
||||||
return TicketModel(
|
|
||||||
companyId: companyId ?? '',
|
|
||||||
storeId: storeId,
|
|
||||||
ticketType: TicketType.repair, // Valore di default
|
|
||||||
ticketStatus: TicketStatus.open,
|
|
||||||
customerPrice: 0.0,
|
|
||||||
internalCost: 0.0,
|
|
||||||
hasCourtesyDevice: false,
|
|
||||||
request: '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TicketModel copyWith({
|
|
||||||
String? id,
|
|
||||||
DateTime? createdAt,
|
|
||||||
String? companyId,
|
|
||||||
String? storeId,
|
|
||||||
String? customerId,
|
|
||||||
String? targetModelId,
|
|
||||||
String? targetSn,
|
|
||||||
String? sourceModelId,
|
|
||||||
String? sourceSn,
|
|
||||||
double? customerPrice,
|
|
||||||
double? internalCost,
|
|
||||||
DateTime? closedAt,
|
|
||||||
DateTime? returnedAt,
|
|
||||||
String? request,
|
|
||||||
WarrantyType? warrantyType,
|
|
||||||
String? publicNotes,
|
|
||||||
String? internalNotes,
|
|
||||||
String? referenceId,
|
|
||||||
String? alternativePhoneNumber,
|
|
||||||
bool? hasCourtesyDevice,
|
|
||||||
TicketType? ticketType,
|
|
||||||
TicketStatus? ticketStatus,
|
|
||||||
DateTime? estimatedDeliveryAt,
|
|
||||||
TicketResult? ticketResult,
|
|
||||||
String? resolutionNotes,
|
|
||||||
CustomerModel? customer,
|
|
||||||
String? targetModelName,
|
|
||||||
String? sourceModelName,
|
|
||||||
String? createdById,
|
|
||||||
String? createdByName,
|
|
||||||
String? assignedToId,
|
|
||||||
String? assignedToName,
|
|
||||||
String? includedAccessories,
|
|
||||||
}) {
|
|
||||||
return TicketModel(
|
|
||||||
id: id ?? this.id,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
|
||||||
companyId: companyId ?? this.companyId,
|
|
||||||
storeId: storeId ?? this.storeId,
|
|
||||||
customerId: customerId ?? this.customerId,
|
|
||||||
targetModelId: targetModelId ?? this.targetModelId,
|
|
||||||
targetSn: targetSn ?? this.targetSn,
|
|
||||||
sourceModelId: sourceModelId ?? this.sourceModelId,
|
|
||||||
sourceSn: sourceSn ?? this.sourceSn,
|
|
||||||
customerPrice: customerPrice ?? this.customerPrice,
|
|
||||||
internalCost: internalCost ?? this.internalCost,
|
|
||||||
closedAt: closedAt ?? this.closedAt,
|
|
||||||
returnedAt: returnedAt ?? this.returnedAt,
|
|
||||||
request: request ?? this.request,
|
|
||||||
warrantyType: warrantyType ?? this.warrantyType,
|
|
||||||
publicNotes: publicNotes ?? this.publicNotes,
|
|
||||||
internalNotes: internalNotes ?? this.internalNotes,
|
|
||||||
referenceId: referenceId ?? this.referenceId,
|
|
||||||
alternativePhoneNumber:
|
|
||||||
alternativePhoneNumber ?? this.alternativePhoneNumber,
|
|
||||||
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
|
|
||||||
ticketType: ticketType ?? this.ticketType,
|
|
||||||
ticketStatus: ticketStatus ?? this.ticketStatus,
|
|
||||||
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
|
|
||||||
ticketResult: ticketResult ?? this.ticketResult,
|
|
||||||
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
|
|
||||||
customer: customer ?? this.customer,
|
|
||||||
targetModelName: targetModelName ?? this.targetModelName,
|
|
||||||
sourceModelName: sourceModelName ?? this.sourceModelName,
|
|
||||||
createdById: createdById ?? this.createdById,
|
|
||||||
createdByName: createdByName ?? this.createdByName,
|
|
||||||
assignedToId: assignedToId ?? this.assignedToId,
|
|
||||||
assignedToName: assignedToName ?? this.assignedToName,
|
|
||||||
includedAccessories: includedAccessories ?? this.includedAccessories,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserializzazione da Supabase
|
|
||||||
factory TicketModel.fromMap(Map<String, dynamic> map) {
|
|
||||||
return TicketModel(
|
|
||||||
id: map['id'] as String,
|
|
||||||
createdAt: map['created_at'] != null
|
|
||||||
? DateTime.parse(map['created_at']).toLocal()
|
|
||||||
: null,
|
|
||||||
companyId: map['company_id'] as String,
|
|
||||||
storeId: map['store_id'] as String?,
|
|
||||||
customerId: map['customer_id'] as String?,
|
|
||||||
targetModelId: map['target_model_id'] as String?,
|
|
||||||
targetSn: map['target_sn'] as String?,
|
|
||||||
sourceModelId: map['source_model_id'] as String?,
|
|
||||||
sourceSn: map['source_sn'] as String?,
|
|
||||||
// Fix per i field numerici di Postgres che potrebbero arrivare come int o double
|
|
||||||
customerPrice: (map['customer_price'] as num?)?.toDouble() ?? 0.0,
|
|
||||||
internalCost: (map['internal_cost'] as num?)?.toDouble() ?? 0.0,
|
|
||||||
closedAt: map['closed_at'] != null
|
|
||||||
? DateTime.parse(map['closed_at']).toLocal()
|
|
||||||
: null,
|
|
||||||
returnedAt: map['returned_at'] != null
|
|
||||||
? DateTime.parse(map['returned_at']).toLocal()
|
|
||||||
: null,
|
|
||||||
request: map['request'] as String? ?? '',
|
|
||||||
warrantyType: WarrantyType.fromString(map['warranty_type'] as String?),
|
|
||||||
publicNotes: map['public_notes'] as String?,
|
|
||||||
internalNotes: map['internal_notes'] as String?,
|
|
||||||
referenceId: map['reference_id'] as String?,
|
|
||||||
alternativePhoneNumber: map['alternative_phone_number'] as String?,
|
|
||||||
hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false,
|
|
||||||
ticketType: TicketType.fromString(map['ticket_type'] as String),
|
|
||||||
ticketStatus: TicketStatus.fromString(map['ticket_status'] as String),
|
|
||||||
estimatedDeliveryAt: map['estimated_delivery_at'] != null
|
|
||||||
? DateTime.parse(map['estimated_delivery_at']).toLocal()
|
|
||||||
: null,
|
|
||||||
ticketResult: TicketResult.fromString(map['ticket_result'] as String?),
|
|
||||||
resolutionNotes: map['resolution_notes'] as String?,
|
|
||||||
customer: map['customer'] != null
|
|
||||||
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
|
|
||||||
: null,
|
|
||||||
targetModelName: (map['target_model']?['name_with_brand'] as String?)
|
|
||||||
?.myFormat(),
|
|
||||||
sourceModelName: (map['source_model']?['name_with_brand'] as String?)
|
|
||||||
?.myFormat(),
|
|
||||||
createdById: map['staff_id'] as String?,
|
|
||||||
createdByName: (map['staff']?['name'] as String?).myFormat(),
|
|
||||||
assignedToId: map['assigned_to_id'] as String?,
|
|
||||||
assignedToName: (map['assigned_to']?['name'] as String?).myFormat(),
|
|
||||||
includedAccessories: map['included_accessories'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializzazione per Supabase
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
return {
|
|
||||||
if (id != null) 'id': id,
|
|
||||||
'company_id': companyId,
|
|
||||||
'store_id': storeId,
|
|
||||||
'customer_id': customerId,
|
|
||||||
'target_model_id': targetModelId,
|
|
||||||
'target_sn': targetSn,
|
|
||||||
'source_model_id': sourceModelId,
|
|
||||||
'source_sn': sourceSn,
|
|
||||||
'customer_price': customerPrice,
|
|
||||||
'internal_cost': internalCost,
|
|
||||||
if (closedAt != null) 'closed_at': closedAt!.toUtc().toIso8601String(),
|
|
||||||
if (returnedAt != null)
|
|
||||||
'returned_at': returnedAt!.toUtc().toIso8601String(),
|
|
||||||
'request': request,
|
|
||||||
'created_by_id': createdById,
|
|
||||||
'warranty_type': warrantyType,
|
|
||||||
'public_notes': publicNotes,
|
|
||||||
'internal_notes': internalNotes,
|
|
||||||
'reference_id': referenceId,
|
|
||||||
'alternative_phone_number': alternativePhoneNumber,
|
|
||||||
'has_courtesy_device': hasCourtesyDevice,
|
|
||||||
'ticket_type': ticketType.value,
|
|
||||||
'ticket_status': ticketStatus.value,
|
|
||||||
if (estimatedDeliveryAt != null)
|
|
||||||
'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(),
|
|
||||||
if (ticketResult != null) 'ticket_result': ticketResult!.value,
|
|
||||||
'resolution_notes': resolutionNotes,
|
|
||||||
'included_accessories': includedAccessories,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
id,
|
|
||||||
createdAt,
|
|
||||||
companyId,
|
|
||||||
storeId,
|
|
||||||
customerId,
|
|
||||||
targetModelId,
|
|
||||||
targetSn,
|
|
||||||
sourceModelId,
|
|
||||||
sourceSn,
|
|
||||||
customerPrice,
|
|
||||||
internalCost,
|
|
||||||
closedAt,
|
|
||||||
returnedAt,
|
|
||||||
request,
|
|
||||||
warrantyType,
|
|
||||||
publicNotes,
|
|
||||||
internalNotes,
|
|
||||||
alternativePhoneNumber,
|
|
||||||
hasCourtesyDevice,
|
|
||||||
ticketType,
|
|
||||||
ticketStatus,
|
|
||||||
estimatedDeliveryAt,
|
|
||||||
ticketResult,
|
|
||||||
resolutionNotes,
|
|
||||||
includedAccessories,
|
|
||||||
customer,
|
|
||||||
targetModelName,
|
|
||||||
sourceModelName,
|
|
||||||
createdById,
|
|
||||||
createdByName,
|
|
||||||
assignedToId,
|
|
||||||
assignedToName,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
|
|
||||||
extension TicketStatusVisuals on TicketStatus {
|
|
||||||
Color get color {
|
|
||||||
switch (this) {
|
|
||||||
case TicketStatus.open:
|
|
||||||
return Colors.blueGrey;
|
|
||||||
case TicketStatus.waitingForParts:
|
|
||||||
return Colors.amber.shade700;
|
|
||||||
case TicketStatus.inProgress:
|
|
||||||
return Colors.blue;
|
|
||||||
case TicketStatus.waitingForShipping:
|
|
||||||
// Il tuo rosa storico!
|
|
||||||
return Colors.pinkAccent;
|
|
||||||
case TicketStatus.waitingForReturn:
|
|
||||||
return Colors.purpleAccent;
|
|
||||||
case TicketStatus.ready:
|
|
||||||
return Colors.green;
|
|
||||||
case TicketStatus.closed:
|
|
||||||
return Colors.grey.shade400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData get icon {
|
|
||||||
switch (this) {
|
|
||||||
case TicketStatus.open:
|
|
||||||
return Icons.inbox;
|
|
||||||
case TicketStatus.waitingForParts:
|
|
||||||
return Icons.hourglass_empty;
|
|
||||||
case TicketStatus.inProgress:
|
|
||||||
return Icons.build;
|
|
||||||
case TicketStatus.waitingForShipping:
|
|
||||||
return Icons.local_shipping_outlined;
|
|
||||||
case TicketStatus.waitingForReturn:
|
|
||||||
return Icons.undo;
|
|
||||||
case TicketStatus.ready:
|
|
||||||
return Icons.check_circle_outline;
|
|
||||||
case TicketStatus.closed:
|
|
||||||
return Icons.lock_outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,309 +0,0 @@
|
|||||||
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/widgets/staff_selector_modal.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|
||||||
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
|
|
||||||
import 'package:flux/features/tickets/blocs/ticket_list_state.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
|
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class TicketListScreen extends StatefulWidget {
|
|
||||||
const TicketListScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<TicketListScreen> createState() => _TicketListScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TicketListScreenState extends State<TicketListScreen> {
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// INFINITY SCROLL: Quando arriviamo quasi in fondo, chiediamo altri ticket
|
|
||||||
_scrollController.addListener(() {
|
|
||||||
if (_scrollController.position.pixels >=
|
|
||||||
_scrollController.position.maxScrollExtent - 200) {
|
|
||||||
context.read<TicketListCubit>().fetchTickets();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Assistenza & Riparazioni'),
|
|
||||||
actions: [
|
|
||||||
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.filter_list),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Aprire BottomSheet filtri avanzati
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
// 1. BARRA DI RICERCA
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Cerca per nome cliente...',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
context.read<TicketListCubit>().updateFilters(
|
|
||||||
clearSearch: true,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onSubmitted: (value) {
|
|
||||||
context.read<TicketListCubit>().updateFilters(
|
|
||||||
searchTerm: value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 2. FILTRI RAPIDI PER STATO (CHIPS)
|
|
||||||
BlocBuilder<TicketListCubit, TicketListState>(
|
|
||||||
buildWhen: (previous, current) =>
|
|
||||||
previous.statusFilter != current.statusFilter,
|
|
||||||
builder: (context, state) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 50,
|
|
||||||
child: ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
children: [
|
|
||||||
_buildStatusChip(context, state, null, 'Tutti'),
|
|
||||||
...TicketStatus.values.map(
|
|
||||||
(status) => _buildStatusChip(
|
|
||||||
context,
|
|
||||||
state,
|
|
||||||
status,
|
|
||||||
status.displayValue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// 3. LA LISTA DEI TICKET
|
|
||||||
Expanded(
|
|
||||||
child: BlocBuilder<TicketListCubit, TicketListState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
if (state.isLoading && state.tickets.isEmpty) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.tickets.isEmpty) {
|
|
||||||
return const Center(child: Text('Nessun ticket trovato.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
itemCount: state.hasReachedMax
|
|
||||||
? state.tickets.length
|
|
||||||
: state.tickets.length + 1,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
// Se siamo all'ultimo elemento e non abbiamo raggiunto il max, mostriamo il loader
|
|
||||||
if (index >= state.tickets.length) {
|
|
||||||
return const Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16.0),
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final ticket = state.tickets[index];
|
|
||||||
return _TicketCard(ticket: ticket);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Nuovo Ticket'),
|
|
||||||
onPressed: () async {
|
|
||||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
|
||||||
if (createdBy == null || !context.mounted) return;
|
|
||||||
context.pushNamed(
|
|
||||||
Routes.ticketForm,
|
|
||||||
pathParameters: {'id': 'new'},
|
|
||||||
extra: (createdBy: createdBy, ticket: null),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Widget di supporto per creare le Chip di filtro
|
|
||||||
Widget _buildStatusChip(
|
|
||||||
BuildContext context,
|
|
||||||
TicketListState state,
|
|
||||||
TicketStatus? status,
|
|
||||||
String label,
|
|
||||||
) {
|
|
||||||
final isSelected = state.statusFilter == status;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(label),
|
|
||||||
selected: isSelected,
|
|
||||||
selectedColor:
|
|
||||||
status?.color.withValues(alpha: 0.2) ??
|
|
||||||
Colors.blue.withValues(alpha: 0.2),
|
|
||||||
onSelected: (selected) {
|
|
||||||
context.read<TicketListCubit>().updateFilters(
|
|
||||||
statusFilter: selected ? status : null,
|
|
||||||
clearStatus: !selected && status != null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// LA CARD DEL TICKET (Il "Colpo d'Occhio")
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
class _TicketCard extends StatelessWidget {
|
|
||||||
final TicketModel ticket;
|
|
||||||
|
|
||||||
const _TicketCard({required this.ticket});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final statusColor = ticket.ticketStatus.color;
|
|
||||||
final statusIcon = ticket.ticketStatus.icon;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
|
||||||
clipBehavior: Clip
|
|
||||||
.antiAlias, // Serve per tagliare il container laterale con gli angoli della card
|
|
||||||
child: IntrinsicHeight(
|
|
||||||
// Serve per far sì che il container laterale prenda tutta l'altezza
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// LA STRISCIA COLORATA LATERALE
|
|
||||||
Container(width: 6, color: statusColor),
|
|
||||||
Expanded(
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16.0,
|
|
||||||
vertical: 8.0,
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
ticket.customer?.name ?? 'Cliente Sconosciuto',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// IL BADGE DELLO STATO
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: statusColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: statusColor.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(statusIcon, size: 14, color: statusColor),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
ticket.ticketStatus.displayValue,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: statusColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
// MODELLO O TIPO DI INTERVENTO
|
|
||||||
Text(
|
|
||||||
ticket.targetModelName ?? ticket.ticketType.displayValue,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
// DATA CREAZIONE (Es: 04/05/2026)
|
|
||||||
Text(
|
|
||||||
ticket.createdAt != null
|
|
||||||
? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}'
|
|
||||||
: '',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
context.pushNamed(
|
|
||||||
'ticket-form',
|
|
||||||
pathParameters: {'id': ticket.id!},
|
|
||||||
extra:
|
|
||||||
ticket, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
|
||||||
// Teniamo anche il parametro URL per coerenza di routing
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flux/features/tracking/models/tracking_model.dart';
|
|
||||||
|
|
||||||
class TicketTimelineSection extends StatefulWidget {
|
|
||||||
final List<TrackingModel> logs;
|
|
||||||
final void Function(String message, bool isInternal) onAddNote;
|
|
||||||
|
|
||||||
const TicketTimelineSection({
|
|
||||||
super.key,
|
|
||||||
required this.logs,
|
|
||||||
required this.onAddNote,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<TicketTimelineSection> createState() => _TicketTimelineSectionState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TicketTimelineSectionState extends State<TicketTimelineSection> {
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
|
||||||
bool _isInternal = true; // Di default blindiamo tutto a uso interno!
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_textController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _submitNote() {
|
|
||||||
final text = _textController.text.trim();
|
|
||||||
if (text.isNotEmpty) {
|
|
||||||
widget.onAddNote(text, _isInternal);
|
|
||||||
_textController.clear();
|
|
||||||
// Chiudiamo la tastiera se siamo su mobile
|
|
||||||
FocusScope.of(context).unfocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// --- ZONA INPUT ---
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: theme.dividerColor),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _textController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Scrivi un aggiornamento...',
|
|
||||||
border: InputBorder.none,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (_) => _submitNote(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton.filled(
|
|
||||||
onPressed: _submitNote,
|
|
||||||
icon: const Icon(Icons.send, size: 20),
|
|
||||||
tooltip: 'Invia',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_isInternal ? Icons.lock : Icons.public,
|
|
||||||
size: 16,
|
|
||||||
color: _isInternal
|
|
||||||
? Colors.amber.shade700
|
|
||||||
: Colors.green,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_isInternal
|
|
||||||
? 'Nota Interna (Privata)'
|
|
||||||
: 'Visibile al Cliente',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _isInternal
|
|
||||||
? Colors.amber.shade700
|
|
||||||
: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: _isInternal,
|
|
||||||
activeThumbColor: Colors.amber.shade700,
|
|
||||||
activeTrackColor: Colors.amber,
|
|
||||||
inactiveThumbColor: Colors.green,
|
|
||||||
inactiveTrackColor: Colors.green.withValues(alpha: 0.2),
|
|
||||||
onChanged: (val) {
|
|
||||||
setState(() {
|
|
||||||
_isInternal = val;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// --- TIMELINE SCROLLABILE ---
|
|
||||||
if (widget.logs.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(32.0),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Nessun evento registrato.',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxHeight: 400,
|
|
||||||
), // Limite di altezza
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: widget.logs.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final log = widget.logs[index];
|
|
||||||
final isLast = index == widget.logs.length - 1;
|
|
||||||
|
|
||||||
return IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// --- LINEA E PALLINO ---
|
|
||||||
SizedBox(
|
|
||||||
width: 30,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(top: 4),
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _getEventColor(log.eventType),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.scaffoldBackgroundColor,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!isLast)
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
width: 2,
|
|
||||||
color: theme.dividerColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// --- CONTENUTO EVENTO ---
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 24.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
log.staffName ?? 'Sistema',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_formatDate(log.createdAt),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!log.isInternal) ...[
|
|
||||||
const Spacer(),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.withValues(
|
|
||||||
alpha: 0.1,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.green.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
"PUBBLICO",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
log.message,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getEventColor(TrackingType type) {
|
|
||||||
switch (type) {
|
|
||||||
case TrackingType.statusChange:
|
|
||||||
return Colors.blue;
|
|
||||||
case TrackingType.assignment:
|
|
||||||
return Colors.purple;
|
|
||||||
case TrackingType.systemAlert:
|
|
||||||
return Colors.redAccent;
|
|
||||||
case TrackingType.customerContact:
|
|
||||||
return Colors.teal;
|
|
||||||
case TrackingType.manualNote:
|
|
||||||
// ignore: unreachable_switch_default
|
|
||||||
default:
|
|
||||||
return Colors.amber.shade600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final day = date.day.toString().padLeft(2, '0');
|
|
||||||
final month = date.month.toString().padLeft(2, '0');
|
|
||||||
final hour = date.hour.toString().padLeft(2, '0');
|
|
||||||
final minute = date.minute.toString().padLeft(2, '0');
|
|
||||||
return "$day/$month - $hour:$minute";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
// Importa il tuo TicketModel, Cubit, ecc.
|
|
||||||
|
|
||||||
class TicketWorkspaceScreen extends StatelessWidget {
|
|
||||||
// Passiamo il ticket attuale per avere i dati (o il Cubit se preferisci)
|
|
||||||
final dynamic ticket; // Sostituisci con TicketModel
|
|
||||||
|
|
||||||
const TicketWorkspaceScreen({super.key, required this.ticket});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Banco di Lavoro'),
|
|
||||||
backgroundColor: theme.colorScheme.inversePrimary,
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
// SafeArea in basso per ospitare i bottoni fissi
|
|
||||||
bottomNavigationBar: SafeArea(child: _buildBottomActions(context)),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
_buildDeviceHeader(theme),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildDefectRecap(theme),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
_buildOperationsSection(theme),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 1. HEADER DISPOSITIVO E PASSWORD ---
|
|
||||||
Widget _buildDeviceHeader(ThemeData theme) {
|
|
||||||
return Card(
|
|
||||||
elevation: 0,
|
|
||||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.4),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
side: BorderSide(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'DISPOSITIVO IN LAVORAZIONE',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
ticket.deviceModel ??
|
|
||||||
'Modello Sconosciuto', // Es: "iPhone 13 Pro"
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// IL DATO PIÙ CERCATO DAI TECNICI: LA PASSWORD
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: theme.dividerColor),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'PIN / SBLOCCO',
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
ticket.unlockPassword?.isNotEmpty == true
|
|
||||||
? ticket.unlockPassword!
|
|
||||||
: 'Nessuno',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 2. RECAP DIFETTO ---
|
|
||||||
Widget _buildDefectRecap(ThemeData theme) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Difetto Segnalato',
|
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: theme.dividerColor),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
ticket.defectDescription ?? 'Nessuna descrizione inserita.',
|
|
||||||
style: const TextStyle(fontSize: 16, height: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3. SEZIONE COSTI E RICAMBI (Mockup) ---
|
|
||||||
Widget _buildOperationsSection(ThemeData theme) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Ricambi e Manodopera',
|
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Apri modal per aggiungere una riga di costo
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Aggiungi Voce'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Qui ci andrà un ListView.builder collegato alla tabella dei costi/operazioni
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.dividerColor,
|
|
||||||
style: BorderStyle.solid,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Nessun ricambio o costo inserito.\nClicca su "Aggiungi Voce" per iniziare.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 4. BOTTONI AZIONE FINALI (In basso) ---
|
|
||||||
Widget _buildBottomActions(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, -5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Bottone Pausa / Attesa Ricambi
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
foregroundColor: Colors.orange.shade700,
|
|
||||||
side: BorderSide(color: Colors.orange.shade700),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Logica Metti in Pausa
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.pause),
|
|
||||||
label: const Text(
|
|
||||||
'Metti in Pausa',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Bottone Completa
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
backgroundColor: Colors.green.shade600,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Logica Completa Riparazione
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline),
|
|
||||||
label: const Text(
|
|
||||||
'COMPLETA RIPARAZIONE',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:pdf/pdf.dart';
|
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
|
||||||
import 'package:printing/printing.dart';
|
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
|
||||||
import 'package:flux/features/company/models/company_model.dart';
|
|
||||||
|
|
||||||
class TicketPdfService {
|
|
||||||
final CompanyModel company = GetIt.I.get<SessionCubit>().state.company!;
|
|
||||||
|
|
||||||
/// Funzione principale: Genera il PDF A4 con le due metà
|
|
||||||
Future<pw.Document> generateTicketReceipt(TicketModel ticket) async {
|
|
||||||
final pdf = pw.Document();
|
|
||||||
|
|
||||||
// Carichiamo il font per essere sicuri che i caratteri siano ok
|
|
||||||
final font = await PdfGoogleFonts.robotoRegular();
|
|
||||||
final boldFont = await PdfGoogleFonts.robotoBold();
|
|
||||||
|
|
||||||
pw.Widget customerHalf = await _buildTicketHalf(
|
|
||||||
ticket,
|
|
||||||
company,
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
isForCustomer: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
pw.Widget storeHalf = await _buildTicketHalf(
|
|
||||||
ticket,
|
|
||||||
company,
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
isForCustomer: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pw.Page(
|
|
||||||
pageFormat: PdfPageFormat.a4,
|
|
||||||
margin: const pw.EdgeInsets.all(20),
|
|
||||||
build: (pw.Context context) {
|
|
||||||
return pw.Column(
|
|
||||||
children: [
|
|
||||||
// 1. METÀ SUPERIORE: CLIENTE
|
|
||||||
customerHalf,
|
|
||||||
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
|
|
||||||
// Linea tratteggiata per il taglio
|
|
||||||
pw.Container(
|
|
||||||
margin: const pw.EdgeInsets.symmetric(vertical: 10),
|
|
||||||
child: pw.Text(
|
|
||||||
'-' * 100,
|
|
||||||
style: const pw.TextStyle(color: PdfColors.grey400),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
|
|
||||||
// 2. METÀ INFERIORE: NEGOZIO
|
|
||||||
storeHalf,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper per costruire una singola metà (Cliente o Negozio)
|
|
||||||
Future<pw.Widget> _buildTicketHalf(
|
|
||||||
TicketModel ticket,
|
|
||||||
CompanyModel company,
|
|
||||||
pw.Font font,
|
|
||||||
pw.Font boldFont, {
|
|
||||||
required bool isForCustomer,
|
|
||||||
}) async {
|
|
||||||
return pw.Expanded(
|
|
||||||
//height: 380, // Circa metà A4 meno i margini
|
|
||||||
child: pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// HEADER: Logo e Dati Azienda (Solo per cliente o ID per negozio)
|
|
||||||
pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
pw.Text(
|
|
||||||
company.name,
|
|
||||||
style: pw.TextStyle(font: boldFont, fontSize: 16),
|
|
||||||
),
|
|
||||||
if (isForCustomer) ...[
|
|
||||||
pw.Text(
|
|
||||||
"${company.address}, ${company.city}",
|
|
||||||
style: const pw.TextStyle(fontSize: 10),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
"P.IVA: ${company.vatId}",
|
|
||||||
style: const pw.TextStyle(fontSize: 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pw.Row(
|
|
||||||
children: [
|
|
||||||
pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
pw.Text(
|
|
||||||
isForCustomer
|
|
||||||
? "RICEVUTA CLIENTE"
|
|
||||||
: "COPIA INTERNA NEGOZIO",
|
|
||||||
style: pw.TextStyle(
|
|
||||||
font: boldFont,
|
|
||||||
fontSize: 12,
|
|
||||||
color: PdfColors.grey700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
"Rif: ${ticket.referenceId}",
|
|
||||||
style: pw.TextStyle(font: boldFont, fontSize: 14),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
"Data: ${ticket.createdAt?.toString().substring(0, 10) ?? ''}",
|
|
||||||
style: const pw.TextStyle(fontSize: 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pw.SizedBox(width: 10),
|
|
||||||
// IL NOSTRO QR CODE MAGICO
|
|
||||||
pw.BarcodeWidget(
|
|
||||||
barcode: pw.Barcode.qrCode(),
|
|
||||||
data: ticket.id!, // Salviamo l'ID univoco nel QR!
|
|
||||||
width: 45,
|
|
||||||
height: 45,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
pw.Divider(thickness: 1),
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
|
|
||||||
// DATI CLIENTE
|
|
||||||
pw.Row(
|
|
||||||
children: [
|
|
||||||
pw.Expanded(
|
|
||||||
child: _infoBlock(
|
|
||||||
"CLIENTE",
|
|
||||||
ticket.customer?.name ?? 'Cliente Sconosciuto',
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pw.Expanded(
|
|
||||||
child: _infoBlock(
|
|
||||||
"CONTATTO ALTERNATIVO",
|
|
||||||
ticket.alternativePhoneNumber ?? 'N/D',
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
pw.SizedBox(height: 15),
|
|
||||||
|
|
||||||
// DETTAGLI LAVORAZIONE
|
|
||||||
_infoBlock(
|
|
||||||
"DESCRIZIONE PROBLEMA / LAVORAZIONE RICHIESTA",
|
|
||||||
ticket.request,
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
pw.SizedBox(height: 8),
|
|
||||||
|
|
||||||
pw.Row(
|
|
||||||
children: [
|
|
||||||
pw.Expanded(
|
|
||||||
child: _infoBlock(
|
|
||||||
"ACCESSORI CONSEGNATI",
|
|
||||||
ticket.includedAccessories ?? 'Nessuno',
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pw.Expanded(
|
|
||||||
child: _infoBlock(
|
|
||||||
"GARANZIA",
|
|
||||||
ticket.warrantyType?.displayValue ?? 'Standard',
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
pw.SizedBox(height: 15),
|
|
||||||
|
|
||||||
// NOTE (Pubbliche o Private a seconda della copia)
|
|
||||||
if (isForCustomer)
|
|
||||||
_infoBlock("NOTE", ticket.publicNotes ?? '-', font, boldFont)
|
|
||||||
else
|
|
||||||
_infoBlock(
|
|
||||||
"NOTE INTERNE (PRIVATE)",
|
|
||||||
ticket.internalNotes ?? '-',
|
|
||||||
font,
|
|
||||||
boldFont,
|
|
||||||
),
|
|
||||||
|
|
||||||
pw.Spacer(),
|
|
||||||
|
|
||||||
// FOOTER: Disclaimer e Firma
|
|
||||||
if (!isForCustomer) ...[
|
|
||||||
pw.Text(
|
|
||||||
"CONDIZIONI E LIBERATORIA:",
|
|
||||||
style: pw.TextStyle(font: boldFont, fontSize: 8),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
company.ticketDisclaimer ??
|
|
||||||
'Firma per accettazione delle condizioni di riparazione.',
|
|
||||||
style: const pw.TextStyle(fontSize: 7),
|
|
||||||
textAlign: pw.TextAlign.justify,
|
|
||||||
),
|
|
||||||
pw.SizedBox(height: 20),
|
|
||||||
pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pw.Container(
|
|
||||||
width: 150,
|
|
||||||
decoration: pw.BoxDecoration(
|
|
||||||
border: const pw.Border(top: pw.BorderSide(width: 0.5)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
"Firma del Cliente per accettazione",
|
|
||||||
style: const pw.TextStyle(fontSize: 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
pw.Align(
|
|
||||||
alignment: pw.Alignment.centerRight,
|
|
||||||
child: pw.Text(
|
|
||||||
"Grazie per averci scelto!",
|
|
||||||
style: pw.TextStyle(
|
|
||||||
font: font,
|
|
||||||
fontSize: 10,
|
|
||||||
fontStyle: pw.FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pw.Widget _infoBlock(
|
|
||||||
String label,
|
|
||||||
String value,
|
|
||||||
pw.Font font,
|
|
||||||
pw.Font boldFont,
|
|
||||||
) {
|
|
||||||
return pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
pw.Text(
|
|
||||||
label,
|
|
||||||
style: pw.TextStyle(
|
|
||||||
font: boldFont,
|
|
||||||
fontSize: 8,
|
|
||||||
color: PdfColors.grey600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pw.Text(value, style: pw.TextStyle(font: font, fontSize: 11)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<pw.Document> generateLabelPdf(TicketModel ticket) async {
|
|
||||||
final pdf = pw.Document();
|
|
||||||
final font = await PdfGoogleFonts.robotoRegular();
|
|
||||||
final boldFont = await PdfGoogleFonts.robotoBold();
|
|
||||||
|
|
||||||
// Prendiamo le misure salvate (se custom) o usiamo default
|
|
||||||
final widthMm = company.labelWidth ?? 62.0;
|
|
||||||
final heightMm = company.labelHeight ?? 29.0;
|
|
||||||
|
|
||||||
// Creiamo il formato fisico esatto!
|
|
||||||
final format = company.isLabelVertical
|
|
||||||
? PdfPageFormat(heightMm * PdfPageFormat.mm, widthMm * PdfPageFormat.mm)
|
|
||||||
: PdfPageFormat(
|
|
||||||
widthMm * PdfPageFormat.mm,
|
|
||||||
heightMm * PdfPageFormat.mm,
|
|
||||||
);
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pw.Page(
|
|
||||||
pageFormat: format,
|
|
||||||
margin: const pw.EdgeInsets.all(2), // Margini minimi per le etichette
|
|
||||||
build: (context) {
|
|
||||||
return pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pw.Expanded(
|
|
||||||
child: pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
pw.Text(
|
|
||||||
ticket.referenceId ?? '',
|
|
||||||
style: pw.TextStyle(font: boldFont, fontSize: 10),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
ticket.customer?.name ?? 'Cliente sconosciuto',
|
|
||||||
style: pw.TextStyle(font: font, fontSize: 9),
|
|
||||||
),
|
|
||||||
pw.Text(
|
|
||||||
ticket.createdAt?.toString().substring(0, 10) ?? '',
|
|
||||||
style: const pw.TextStyle(fontSize: 7),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// QR Code compatto
|
|
||||||
pw.BarcodeWidget(
|
|
||||||
barcode: pw.Barcode.qrCode(),
|
|
||||||
data: ticket.id!,
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return pdf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/features/tracking/data/tracking_repository.dart';
|
|
||||||
import 'package:flux/features/tracking/models/tracking_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
|
|
||||||
// Stati base: initial, loading, loaded, error
|
|
||||||
class TrackingState {
|
|
||||||
final bool isLoading;
|
|
||||||
final List<TrackingModel> logs;
|
|
||||||
|
|
||||||
TrackingState({this.isLoading = false, this.logs = const []});
|
|
||||||
}
|
|
||||||
|
|
||||||
class TrackingCubit extends Cubit<TrackingState> {
|
|
||||||
final TrackingRepository _repo = GetIt.I.get<TrackingRepository>();
|
|
||||||
final String parentId;
|
|
||||||
final TrackingParentType parentType;
|
|
||||||
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
|
||||||
|
|
||||||
TrackingCubit({required this.parentId, required this.parentType})
|
|
||||||
: super(TrackingState()) {
|
|
||||||
loadTrackings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadTrackings() async {
|
|
||||||
emit(TrackingState(isLoading: true, logs: state.logs));
|
|
||||||
final trackings = await _repo.getTrackingsByParent(
|
|
||||||
parentId: parentId,
|
|
||||||
parentType: parentType,
|
|
||||||
);
|
|
||||||
emit(TrackingState(isLoading: false, logs: trackings));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addManualNote(
|
|
||||||
String message,
|
|
||||||
bool isInternal, {
|
|
||||||
String? staffId,
|
|
||||||
}) async {
|
|
||||||
// Aggiungiamo un feedback visivo immediato (Optimistic UI) se vogliamo,
|
|
||||||
// oppure semplicemente mostriamo il loading
|
|
||||||
await _repo.logQuickEvent(
|
|
||||||
companyId: companyId,
|
|
||||||
message: message,
|
|
||||||
type: TrackingType.manualNote,
|
|
||||||
parentId: parentId!,
|
|
||||||
parentType: parentType,
|
|
||||||
staffId: staffId,
|
|
||||||
isInternal: isInternal,
|
|
||||||
);
|
|
||||||
// Ricarichiamo la lista fresca dal server
|
|
||||||
await loadTrackings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import 'package:flux/features/tracking/models/tracking_model.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
class TrackingRepository {
|
|
||||||
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
|
||||||
|
|
||||||
TrackingRepository();
|
|
||||||
|
|
||||||
/// Recupera la cronologia di un'entità (Ticket o Operazione)
|
|
||||||
Future<List<TrackingModel>> getTrackingsByParent({
|
|
||||||
required String parentId, // <-- Reso obbligatorio
|
|
||||||
required TrackingParentType parentType, // <-- Reso obbligatorio
|
|
||||||
}) async {
|
|
||||||
// Facciamo la query con la JOIN per recuperare il nome dello staff al volo
|
|
||||||
final response = await _supabase
|
|
||||||
.from('tracking')
|
|
||||||
.select('*, staff_member(name)')
|
|
||||||
.eq('parent_id', parentId)
|
|
||||||
.eq('parent_type', parentType.name)
|
|
||||||
.order(
|
|
||||||
'created_at',
|
|
||||||
ascending: true,
|
|
||||||
); // ascending: true per avere la timeline dall'alto (vecchi) al basso (nuovi)
|
|
||||||
|
|
||||||
return response.map((map) => TrackingModel.fromMap(map)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserisce un nuovo evento di tracking
|
|
||||||
Future<void> logEvent(TrackingModel tracking) async {
|
|
||||||
await _supabase.from('tracking').insert(tracking.toMap());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metodo helper rapido per loggare un cambio di stato o una nota
|
|
||||||
Future<void> logQuickEvent({
|
|
||||||
required String companyId,
|
|
||||||
required String message,
|
|
||||||
required TrackingType type,
|
|
||||||
required String parentId, // <-- Reso obbligatorio
|
|
||||||
required TrackingParentType parentType, // <-- Reso obbligatorio
|
|
||||||
String? staffId,
|
|
||||||
bool isInternal = true,
|
|
||||||
}) async {
|
|
||||||
final log = TrackingModel(
|
|
||||||
createdAt:
|
|
||||||
DateTime.now(), // Questo verrà ignorato dal toMap in fase di insert, ma serve al modello
|
|
||||||
companyId: companyId,
|
|
||||||
staffId: staffId,
|
|
||||||
parentId: parentId,
|
|
||||||
parentType: parentType,
|
|
||||||
eventType: type,
|
|
||||||
isInternal: isInternal,
|
|
||||||
message: message,
|
|
||||||
);
|
|
||||||
await logEvent(log);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
enum TrackingType {
|
|
||||||
statusChange,
|
|
||||||
manualNote,
|
|
||||||
systemAlert,
|
|
||||||
customerContact,
|
|
||||||
assignment;
|
|
||||||
|
|
||||||
static TrackingType fromString(String value) {
|
|
||||||
return TrackingType.values.firstWhere(
|
|
||||||
(e) => e.name == value,
|
|
||||||
orElse: () => TrackingType.manualNote,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TrackingParentType {
|
|
||||||
ticket,
|
|
||||||
operation;
|
|
||||||
|
|
||||||
String get value => name;
|
|
||||||
|
|
||||||
static TrackingParentType fromString(String val) {
|
|
||||||
return TrackingParentType.values.firstWhere(
|
|
||||||
(e) => e.name == val,
|
|
||||||
orElse: () => TrackingParentType.ticket, // Default di sicurezza
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TrackingModel extends Equatable {
|
|
||||||
final String? id;
|
|
||||||
final DateTime createdAt;
|
|
||||||
final String companyId;
|
|
||||||
final String? staffId;
|
|
||||||
final String? staffName; // Per non fare mille join, lo prendiamo dal repo
|
|
||||||
final String parentId;
|
|
||||||
final TrackingParentType parentType;
|
|
||||||
final TrackingType eventType;
|
|
||||||
final bool isInternal;
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
const TrackingModel({
|
|
||||||
this.id,
|
|
||||||
required this.createdAt,
|
|
||||||
required this.companyId,
|
|
||||||
this.staffId,
|
|
||||||
this.staffName,
|
|
||||||
required this.parentId,
|
|
||||||
required this.parentType,
|
|
||||||
required this.eventType,
|
|
||||||
required this.isInternal,
|
|
||||||
required this.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
TrackingModel copyWith({
|
|
||||||
String? id,
|
|
||||||
DateTime? createdAt,
|
|
||||||
String? companyId,
|
|
||||||
String? staffId,
|
|
||||||
String? staffName,
|
|
||||||
TrackingParentType? parentType,
|
|
||||||
String? parentId,
|
|
||||||
TrackingType? eventType,
|
|
||||||
bool? isInternal,
|
|
||||||
String? message,
|
|
||||||
}) {
|
|
||||||
return TrackingModel(
|
|
||||||
id: id ?? this.id,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
|
||||||
companyId: companyId ?? this.companyId,
|
|
||||||
staffId: staffId ?? this.staffId,
|
|
||||||
staffName: staffName ?? this.staffName,
|
|
||||||
parentId: parentId ?? this.parentId,
|
|
||||||
parentType: parentType ?? this.parentType,
|
|
||||||
eventType: eventType ?? this.eventType,
|
|
||||||
isInternal: isInternal ?? this.isInternal,
|
|
||||||
message: message ?? this.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory TrackingModel.fromMap(Map<String, dynamic> map) {
|
|
||||||
return TrackingModel(
|
|
||||||
id: map['id'],
|
|
||||||
createdAt: DateTime.parse(map['created_at']),
|
|
||||||
companyId: map['company_id'],
|
|
||||||
staffId: map['staff_id'],
|
|
||||||
staffName: map['staff_member']?['name'], // Se fai la join su staff_member
|
|
||||||
parentId: map['parent_id'] as String,
|
|
||||||
parentType: TrackingParentType.fromString(map['parent_type']),
|
|
||||||
eventType: TrackingType.fromString(map['event_type']),
|
|
||||||
isInternal: map['is_internal'] ?? true,
|
|
||||||
message: map['message'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
final map = <String, dynamic>{
|
|
||||||
'company_id': companyId,
|
|
||||||
'staff_id': staffId,
|
|
||||||
'parent_id': parentId,
|
|
||||||
'parent_type': parentType.name,
|
|
||||||
'event_type': eventType.name,
|
|
||||||
'is_internal': isInternal,
|
|
||||||
'message': message,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Aggiungiamo id e data SOLO se stiamo aggiornando un record esistente.
|
|
||||||
// In fase di creazione (insert), li omettiamo così Supabase usa i valori di default (gen_random_uuid e now()).
|
|
||||||
if (id != null) {
|
|
||||||
map['id'] = id;
|
|
||||||
map['created_at'] = createdAt.toIso8601String();
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
id,
|
|
||||||
createdAt,
|
|
||||||
companyId,
|
|
||||||
staffId,
|
|
||||||
staffName,
|
|
||||||
parentId,
|
|
||||||
parentType,
|
|
||||||
eventType,
|
|
||||||
isInternal,
|
|
||||||
message,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
88
lib/firebase_options.dart
Normal file
88
lib/firebase_options.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// File generated by FlutterFire CLI.
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// import 'firebase_options.dart';
|
||||||
|
/// // ...
|
||||||
|
/// await Firebase.initializeApp(
|
||||||
|
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class DefaultFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return web;
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
return macos;
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
return windows;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions web = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyA8vQbyEt81DoAuRVDc_3W_VIKY-9F-XTw',
|
||||||
|
appId: '1:872447580790:web:10745e7f9afb447d5d9d57',
|
||||||
|
messagingSenderId: '872447580790',
|
||||||
|
projectId: 'assistenza-catelli',
|
||||||
|
authDomain: 'assistenza-catelli.firebaseapp.com',
|
||||||
|
storageBucket: 'assistenza-catelli.firebasestorage.app',
|
||||||
|
measurementId: 'G-HTSSNQJ15P',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8',
|
||||||
|
appId: '1:872447580790:android:a1d8d57960451f935d9d57',
|
||||||
|
messagingSenderId: '872447580790',
|
||||||
|
projectId: 'assistenza-catelli',
|
||||||
|
storageBucket: 'assistenza-catelli.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyCkjOTW6BlckKIxQdp5TPnHuRfXFoVC3bY',
|
||||||
|
appId: '1:872447580790:ios:a87d56c718aa61e05d9d57',
|
||||||
|
messagingSenderId: '872447580790',
|
||||||
|
projectId: 'assistenza-catelli',
|
||||||
|
storageBucket: 'assistenza-catelli.firebasestorage.app',
|
||||||
|
iosBundleId: 'com.catellisrl.flux',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions macos = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyCkjOTW6BlckKIxQdp5TPnHuRfXFoVC3bY',
|
||||||
|
appId: '1:872447580790:ios:a87d56c718aa61e05d9d57',
|
||||||
|
messagingSenderId: '872447580790',
|
||||||
|
projectId: 'assistenza-catelli',
|
||||||
|
storageBucket: 'assistenza-catelli.firebasestorage.app',
|
||||||
|
iosBundleId: 'com.catellisrl.flux',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions windows = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyA5uJhb8ksqKqdEWbMD5ra6JYXIGoaIdIM',
|
||||||
|
appId: '1:872447580790:web:3b1623eda6abdac75d9d57',
|
||||||
|
messagingSenderId: '872447580790',
|
||||||
|
projectId: 'assistenza-catelli',
|
||||||
|
authDomain: 'assistenza-catelli.firebaseapp.com',
|
||||||
|
storageBucket: 'assistenza-catelli.firebasestorage.app',
|
||||||
|
measurementId: 'G-J8LZTQ9NHB',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
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:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
||||||
import 'package:flux/features/company/data/company_repository.dart';
|
|
||||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
|
||||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||||
import 'package:flux/features/settings/blocs/settings_cubit.dart';
|
import 'package:flux/firebase_options.dart';
|
||||||
import 'package:flux/features/settings/document_sequence/data/document_sequence_repository.dart';
|
|
||||||
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
|
||||||
import 'package:flux/features/tracking/data/tracking_repository.dart';
|
|
||||||
import 'package:flux/l10n/app_localizations.dart';
|
import 'package:flux/l10n/app_localizations.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';
|
||||||
@@ -33,8 +29,8 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
|||||||
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
|
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
|
||||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||||
import 'package:flux/features/master_data/store/data/store_repository.dart';
|
import 'package:flux/features/master_data/store/data/store_repository.dart';
|
||||||
|
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||||
import 'package:flux/features/settings/settings.dart';
|
import 'package:flux/features/settings/settings.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -42,8 +38,7 @@ void main() async {
|
|||||||
|
|
||||||
// Inizializza le dipendenze PRIMA di lanciare l'app
|
// Inizializza le dipendenze PRIMA di lanciare l'app
|
||||||
await setupLocator();
|
await setupLocator();
|
||||||
// RIMUOVE IL CARATTERE # DAGLI URL WEB!
|
|
||||||
usePathUrlStrategy();
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiBlocProvider(
|
MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -58,15 +53,9 @@ void main() async {
|
|||||||
BlocProvider<StoreCubit>(create: (_) => StoreCubit()),
|
BlocProvider<StoreCubit>(create: (_) => StoreCubit()),
|
||||||
BlocProvider<CustomersCubit>(create: (_) => CustomersCubit()),
|
BlocProvider<CustomersCubit>(create: (_) => CustomersCubit()),
|
||||||
BlocProvider<ProductsCubit>(create: (_) => ProductsCubit()),
|
BlocProvider<ProductsCubit>(create: (_) => ProductsCubit()),
|
||||||
BlocProvider<StaffCubit>(
|
BlocProvider<StaffCubit>(create: (_) => StaffCubit()),
|
||||||
create: (_) => StaffCubit()
|
BlocProvider<OperationsCubit>(create: (_) => OperationsCubit()),
|
||||||
..loadStaffForStore(
|
|
||||||
GetIt.I.get<SessionCubit>().state.currentStore!.id!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BlocProvider<OperationListCubit>(create: (_) => OperationListCubit()),
|
|
||||||
BlocProvider<ProvidersCubit>(create: (_) => ProvidersCubit()),
|
BlocProvider<ProvidersCubit>(create: (_) => ProvidersCubit()),
|
||||||
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
|
||||||
],
|
],
|
||||||
child: const FluxApp(),
|
child: const FluxApp(),
|
||||||
),
|
),
|
||||||
@@ -105,10 +94,6 @@ Future<void> setupLocator() async {
|
|||||||
getIt.registerLazySingleton<AttachmentsRepository>(
|
getIt.registerLazySingleton<AttachmentsRepository>(
|
||||||
() => AttachmentsRepository(),
|
() => AttachmentsRepository(),
|
||||||
);
|
);
|
||||||
getIt.registerLazySingleton<TicketRepository>(() => TicketRepository());
|
|
||||||
getIt.registerLazySingleton<DocumentSequenceRepository>(
|
|
||||||
() => DocumentSequenceRepository(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
|
// NOTA: CompanyRepository l'ho tolto perché la logica della Company
|
||||||
// ora è gestita dal CoreRepository durante l'Onboarding.
|
// ora è gestita dal CoreRepository durante l'Onboarding.
|
||||||
@@ -119,8 +104,9 @@ Future<void> setupLocator() async {
|
|||||||
getIt.registerSingleton<SessionCubit>(
|
getIt.registerSingleton<SessionCubit>(
|
||||||
SessionCubit(getIt<CoreRepository>(), getIt<SharedPreferences>()),
|
SessionCubit(getIt<CoreRepository>(), getIt<SharedPreferences>()),
|
||||||
);
|
);
|
||||||
getIt.registerLazySingleton<CompanyRepository>(() => CompanyRepository());
|
//TODO rimuovere dopo gli import
|
||||||
getIt.registerLazySingleton<TrackingRepository>(() => TrackingRepository());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
}
|
}
|
||||||
|
|
||||||
class FluxApp extends StatefulWidget {
|
class FluxApp extends StatefulWidget {
|
||||||
|
|||||||
336
lib/temp/migration_tools.dart
Normal file
336
lib/temp/migration_tools.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
Future<void> migrateCustomersToSupabase() async {
|
||||||
|
// 1. IL TUO COMPANY ID REALE SU SUPABASE
|
||||||
|
// Vai nel database Supabase, copia l'UUID della tua azienda e incollalo qui
|
||||||
|
final String myRealCompanyId = '6c4b2323-2a60-4d33-bf21-c5a8eb6b4a5b';
|
||||||
|
|
||||||
|
try {
|
||||||
|
print("Inizio download modello da Firebase...");
|
||||||
|
|
||||||
|
// 2. Scarichiamo TUTTI i clienti da Firebase
|
||||||
|
final snapshot = await FirebaseFirestore.instance.collection('marca').get();
|
||||||
|
|
||||||
|
if (snapshot.docs.isEmpty) {
|
||||||
|
print("Nessun marca trovato su Firebase!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Questa lista conterrà i dati formattati pronti per Supabase
|
||||||
|
List<Map<String, dynamic>> supabaseBrands = [];
|
||||||
|
|
||||||
|
// 3. Cicliamo i documenti di Firebase e li trasformiamo
|
||||||
|
for (var doc in snapshot.docs) {
|
||||||
|
final data = doc.data();
|
||||||
|
|
||||||
|
// Creiamo la riga per Supabase
|
||||||
|
supabaseBrands.add({
|
||||||
|
'legacy_id': doc.id, // L'ID vecchio di Firebase
|
||||||
|
//'company_id': myRealCompanyId, // ECCO IL TUO COMPANY ID!
|
||||||
|
// Mappa i campi (attento a far combaciare i nomi esatti delle colonne Supabase!)
|
||||||
|
'name': (data['nome'] as String).trim().toLowerCase(),
|
||||||
|
|
||||||
|
'company_id': myRealCompanyId,
|
||||||
|
|
||||||
|
// Se avevi una data di creazione su Firebase, convertila, altrimenti ignorala
|
||||||
|
// e Supabase userà il suo 'default now()'
|
||||||
|
// 'created_at': (data['createdAt'] as Timestamp?)?.toDate().toIso8601String(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Sto per inviare ${supabaseBrands.length} brand a Supabase...");
|
||||||
|
|
||||||
|
// 4. Invio a Supabase con UPSERT
|
||||||
|
await Supabase.instance.client
|
||||||
|
.from('brand')
|
||||||
|
.upsert(
|
||||||
|
supabaseBrands,
|
||||||
|
onConflict:
|
||||||
|
'legacy_id', // Se il legacy_id c'è già, aggiorna invece di duplicare
|
||||||
|
);
|
||||||
|
|
||||||
|
print("BOOM! Migrazione brand completata con successo! 🚀");
|
||||||
|
} catch (e) {
|
||||||
|
print("Porca miseria, errore durante la migrazione: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> migrateModelsToSupabase() async {
|
||||||
|
final String myRealCompanyId = '6c4b2323-2a60-4d33-bf21-c5a8eb6b4a5b';
|
||||||
|
|
||||||
|
try {
|
||||||
|
print("Inizio migrazione Modelli...");
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// FASE 1: CREAZIONE DEL DIZIONARIO DI TRADUZIONE (LA MAGIA)
|
||||||
|
// ==========================================================
|
||||||
|
print("Scarico i Brand da Supabase per tradurre gli ID...");
|
||||||
|
|
||||||
|
// Chiediamo a Supabase solo 2 colonne: il nuovo UUID e il vecchio ID di Firebase
|
||||||
|
final List<dynamic> brandResponse = await Supabase.instance.client
|
||||||
|
.from('brand')
|
||||||
|
.select('id, legacy_id');
|
||||||
|
|
||||||
|
// Creiamo la mappa: la chiave è il vecchio ID, il valore è il nuovo UUID
|
||||||
|
Map<String, String> brandTranslationMap = {};
|
||||||
|
for (var b in brandResponse) {
|
||||||
|
if (b['legacy_id'] != null) {
|
||||||
|
brandTranslationMap[b['legacy_id']] = b['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Dizionario pronto! Trovati ${brandTranslationMap.length} Brand.");
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// FASE 2: SCARICAMENTO E TRADUZIONE DEI MODELLI
|
||||||
|
// ==========================================================
|
||||||
|
final snapshot = await FirebaseFirestore.instance
|
||||||
|
.collection('modello')
|
||||||
|
.get(); // Controlla il nome esatto della collection!
|
||||||
|
|
||||||
|
if (snapshot.docs.isEmpty) {
|
||||||
|
print("Nessun modello trovato su Firebase!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> supabaseModels = [];
|
||||||
|
|
||||||
|
for (var doc in snapshot.docs) {
|
||||||
|
final data = doc.data();
|
||||||
|
|
||||||
|
// 1. Prendiamo il vecchio ID del brand salvato su Firebase
|
||||||
|
String? oldFirebaseBrandId = data['idMarca'];
|
||||||
|
|
||||||
|
// 2. TRADUZIONE ISTANTANEA! Cerchiamo il nuovo UUID nel nostro dizionario
|
||||||
|
String? newSupabaseBrandUuid;
|
||||||
|
if (oldFirebaseBrandId != null) {
|
||||||
|
newSupabaseBrandUuid = brandTranslationMap[oldFirebaseBrandId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Controllo di sicurezza: se il brand non esiste su Supabase, saltiamo il record o mettiamo null?
|
||||||
|
// Se nella tua tabella 'model' il 'brand_id' NON PUÒ essere null, devi per forza avere un match!
|
||||||
|
if (newSupabaseBrandUuid == null && oldFirebaseBrandId != null) {
|
||||||
|
print(
|
||||||
|
"ATTENZIONE: Il modello ${data['nome']} ha un brand_id ($oldFirebaseBrandId) che non esiste su Supabase. Salto o metto null.",
|
||||||
|
);
|
||||||
|
continue; // Decommenta questo se vuoi saltare i modelli orfani
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creiamo la riga per Supabase
|
||||||
|
supabaseModels.add({
|
||||||
|
'legacy_id': doc.id,
|
||||||
|
|
||||||
|
// ECCO LA CHIAVE ESTERNA TRADOTTA!
|
||||||
|
'brand_id': newSupabaseBrandUuid,
|
||||||
|
|
||||||
|
// Mappa gli altri campi
|
||||||
|
'name': (data['nome'] as String).trim().toLowerCase(),
|
||||||
|
'name_with_brand': (data['nomeConMarca'] as String)
|
||||||
|
.toLowerCase()
|
||||||
|
.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// FASE 3: INVIO A SUPABASE
|
||||||
|
// ==========================================================
|
||||||
|
print("Sto per inviare ${supabaseModels.length} modelli a Supabase...");
|
||||||
|
|
||||||
|
await Supabase.instance.client
|
||||||
|
.from('model')
|
||||||
|
.upsert(supabaseModels, onConflict: 'legacy_id');
|
||||||
|
|
||||||
|
print("BOOM! Migrazione modelli completata con successo! 🚀");
|
||||||
|
} catch (e) {
|
||||||
|
print("Errore durante la migrazione dei modelli: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TicketMigrationScript {
|
||||||
|
final SupabaseClient supabase = Supabase.instance.client;
|
||||||
|
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||||
|
final String storeId = GetIt.I.get<SessionCubit>().state.currentStore!.id!;
|
||||||
|
|
||||||
|
/// Esegui questa funzione passandole la stringa JSON grezza (es. copiata da un file)
|
||||||
|
/// e l'ID della tua Company su Supabase (visto che Firebase non lo aveva).
|
||||||
|
Future<void> runMigration(String jsonString) async {
|
||||||
|
debugPrint('🚀 INIZIO MIGRAZIONE TICKET...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Parsing del JSON
|
||||||
|
final Map<String, dynamic> decoded = jsonDecode(jsonString);
|
||||||
|
// Scendiamo al piano di sotto, direttamente nella "pancia" dei dati!
|
||||||
|
final Map<String, dynamic> rawData = decoded['data'];
|
||||||
|
|
||||||
|
debugPrint('Trovati ${rawData.length} elementi alla radice.');
|
||||||
|
if (rawData.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'Il primo elemento contiene: ${rawData.entries.first.value}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CREAZIONE DELLA CACHE (IL TRUCCO PER NON IMPAZZIRE CON LE JOIN)
|
||||||
|
debugPrint('📥 Scarico le mappe dei legacy_id da Supabase...');
|
||||||
|
|
||||||
|
final customersRes = await supabase
|
||||||
|
.from('customer')
|
||||||
|
.select('id, legacy_id')
|
||||||
|
.not('legacy_id', 'is', null);
|
||||||
|
|
||||||
|
final modelsRes = await supabase
|
||||||
|
.from('model')
|
||||||
|
.select('id, legacy_id')
|
||||||
|
.not('legacy_id', 'is', null);
|
||||||
|
|
||||||
|
// Creiamo i dizionari: chiave = legacy_id (Firebase), valore = uuid (Supabase)
|
||||||
|
final Map<String, String> customerMap = {
|
||||||
|
for (var row in customersRes)
|
||||||
|
if (row['legacy_id'] != null)
|
||||||
|
row['legacy_id'].toString(): row['id'].toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final Map<String, String> modelMap = {
|
||||||
|
for (var row in modelsRes)
|
||||||
|
if (row['legacy_id'] != null)
|
||||||
|
row['legacy_id'].toString(): row['id'].toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'✅ Mappe pronte: ${customerMap.length} clienti, ${modelMap.length} modelli.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. MAPPATURA DEI DATI
|
||||||
|
List<Map<String, dynamic>> ticketsToInsert = [];
|
||||||
|
|
||||||
|
for (var entry in rawData.entries) {
|
||||||
|
final data = entry.value as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Recuperiamo le relazioni usando i nostri dizionari
|
||||||
|
final String? customerId = customerMap[data['idCliente']];
|
||||||
|
final String? modelId = modelMap[data['idModello']];
|
||||||
|
|
||||||
|
// Se non troviamo il cliente o il modello, magari loggiamo e saltiamo (o mettiamo null)
|
||||||
|
// Per ora li mettiamo null, ma almeno non spacca il DB
|
||||||
|
|
||||||
|
// Risoluzione Date
|
||||||
|
DateTime? createdAt = _parseFirebaseDate(data['dataAperturaScheda']);
|
||||||
|
//DateTime? closedAt = _parseFirebaseDate(data['dataChiusuraScheda']);
|
||||||
|
//DateTime? returnedAt = _parseFirebaseDate(data['dataRiconsegnaCliente']);
|
||||||
|
|
||||||
|
// Costruzione del Ticket
|
||||||
|
ticketsToInsert.add({
|
||||||
|
'legacy_id': data['fsId'], // Il vecchio ID del doc Firebase
|
||||||
|
'company_id': companyId,
|
||||||
|
'store_id': storeId,
|
||||||
|
'customer_id': customerId,
|
||||||
|
'target_model_id': modelId,
|
||||||
|
'target_sn': data['seriale'] ?? '',
|
||||||
|
'customer_price': data['costoTotaleCliente'] ?? 0.0,
|
||||||
|
'internal_cost': data['costoTotaleNostro'] ?? 0.0,
|
||||||
|
'created_at':
|
||||||
|
createdAt?.toUtc().toIso8601String() ??
|
||||||
|
DateTime.now().toUtc().toIso8601String(),
|
||||||
|
|
||||||
|
//'closed_at': closedAt?.toUtc().toIso8601String(),
|
||||||
|
//'returned_at': returnedAt?.toUtc().toIso8601String(),
|
||||||
|
'request': (data['guasto']?.toString() ?? ''),
|
||||||
|
'included_accessories': data['accessoriConsegnati'],
|
||||||
|
|
||||||
|
'public_notes': data['note'],
|
||||||
|
'internal_notes': data['noteInterne'],
|
||||||
|
'resolution_notes':
|
||||||
|
data['operazioneEffettuata'], // Il nuovo campo di cui parlavamo!
|
||||||
|
|
||||||
|
'alternative_phone_number': data['recapitoCliente'],
|
||||||
|
'has_courtesy_device': data['prestatoMuletto'] ?? false,
|
||||||
|
|
||||||
|
// Mappatura Enums
|
||||||
|
'ticket_type': _mapTicketType(data),
|
||||||
|
'ticket_status': _mapTicketStatus(data),
|
||||||
|
'ticket_result': _mapTicketResult(data['risultato']),
|
||||||
|
|
||||||
|
// 'warranty_type': _mapWarranty(data['nomeTipoGaranzia']), // De-commenta se hai la logica pronta
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. INSERIMENTO BATCH (A botte di 100 per non far arrabbiare Postgres)
|
||||||
|
debugPrint(
|
||||||
|
'🚀 Inizio inserimento di ${ticketsToInsert.length} ticket su Supabase...',
|
||||||
|
);
|
||||||
|
|
||||||
|
const int batchSize = 100;
|
||||||
|
for (int i = 0; i < ticketsToInsert.length; i += batchSize) {
|
||||||
|
final end = (i + batchSize < ticketsToInsert.length)
|
||||||
|
? i + batchSize
|
||||||
|
: ticketsToInsert.length;
|
||||||
|
final batch = ticketsToInsert.sublist(i, end);
|
||||||
|
|
||||||
|
await supabase.from('ticket').insert(batch);
|
||||||
|
debugPrint('✅ Inseriti ticket da $i a $end');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('🎉 MIGRAZIONE COMPLETATA CON SUCCESSO!');
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
debugPrint('❌ ERRORE DURANTE LA MIGRAZIONE: $e');
|
||||||
|
debugPrint(stacktrace.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FUNZIONI DI AIUTO (PARSER E MAPPER) ---
|
||||||
|
|
||||||
|
/// Estrae la data dalla fastidiosa struttura {"__time__": "..."} di Firestore export
|
||||||
|
DateTime? _parseFirebaseDate(dynamic dateData) {
|
||||||
|
if (dateData == null) return null;
|
||||||
|
if (dateData is Map && dateData.containsKey('__time__')) {
|
||||||
|
return DateTime.tryParse(dateData['__time__'].toString());
|
||||||
|
}
|
||||||
|
if (dateData is String) {
|
||||||
|
return DateTime.tryParse(dateData);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converte i boolean di Firebase nel tuo Enum TicketType
|
||||||
|
String _mapTicketType(Map<String, dynamic> data) {
|
||||||
|
if (data['tipoLavorazionePassaggioDati'] == true) return 'data_transfer';
|
||||||
|
if (data['tipoLavorazioneRiparazione'] == true) return 'repair';
|
||||||
|
if (data['tipoLavorazioneConfigurazione'] == true) return 'software_setup';
|
||||||
|
return 'other'; // Include tipoLavorazioneAltro o fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converte la logica di stato di Firebase nel tuo Enum TicketStatus
|
||||||
|
String _mapTicketStatus(Map<String, dynamic> data) {
|
||||||
|
// Se è stato riconsegnato al cliente o ritirato, è chiuso/consegnato
|
||||||
|
if (data['riconsegnato'] == true ||
|
||||||
|
data['nomeStatoScheda'] == 'Ritirato da cliente') {
|
||||||
|
return 'closed'; // o 'closed', in base alla tua logica
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altrimenti valutiamo le stringhe
|
||||||
|
final String statoFirebase =
|
||||||
|
data['nomeStatoScheda']?.toString().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
if (statoFirebase.contains('accettazione')) return 'open';
|
||||||
|
if (statoFirebase.contains('da inviare centro esterno'))
|
||||||
|
return 'waiting_for_shipping';
|
||||||
|
if (statoFirebase.contains('attesa ricambi')) return 'waiting_for_parts';
|
||||||
|
if (statoFirebase.contains('pronto')) return 'ready';
|
||||||
|
if (data['daLavorare'] == true) return 'in_progress';
|
||||||
|
|
||||||
|
return 'closed'; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _mapTicketResult(dynamic risultato) {
|
||||||
|
if (risultato == null || risultato.toString().isEmpty) return null;
|
||||||
|
final r = risultato.toString().toUpperCase();
|
||||||
|
if (r == 'OK') return 'success';
|
||||||
|
if (r == 'KO' || r == 'NON RIPARATO') return 'failure';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <gtk/gtk_plugin.h>
|
#include <gtk/gtk_plugin.h>
|
||||||
#include <printing/printing_plugin.h>
|
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
@@ -18,9 +17,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) gtk_registrar =
|
g_autoptr(FlPluginRegistrar) gtk_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
|
||||||
gtk_plugin_register_with_registrar(gtk_registrar);
|
gtk_plugin_register_with_registrar(gtk_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
|
||||||
printing_plugin_register_with_registrar(printing_registrar);
|
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
gtk
|
gtk
|
||||||
printing
|
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,23 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import app_links
|
import app_links
|
||||||
|
import cloud_firestore
|
||||||
import file_picker
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import firebase_auth
|
||||||
|
import firebase_core
|
||||||
import pdfx
|
import pdfx
|
||||||
import printing
|
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
|
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
|
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
|
||||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
1435
macos/Podfile.lock
1435
macos/Podfile.lock
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,5 @@
|
|||||||
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.print</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.print</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
108
pubspec.lock
108
pubspec.lock
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_flutterfire_internals:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _flutterfire_internals
|
||||||
|
sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.69"
|
||||||
app_links:
|
app_links:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -105,6 +113,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
cloud_firestore:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cloud_firestore
|
||||||
|
sha256: "3ac242332166ae5037bd87bc343744bb96d88d7b13f791492b00958ce5cc6c63"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
|
cloud_firestore_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cloud_firestore_platform_interface
|
||||||
|
sha256: "1bd08b736e1015e8bf5448f5ef67b2087a2380c2c1c7972f8403c1c7b41f5359"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
|
cloud_firestore_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cloud_firestore_web
|
||||||
|
sha256: "18617275ffa2331d3ea058c515ef218bcce2ae13a14bee922563ca6ae2507c26"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.3.0"
|
||||||
code_assets:
|
code_assets:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -241,6 +273,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.3+5"
|
version: "0.9.3+5"
|
||||||
|
firebase_auth:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_auth
|
||||||
|
sha256: b12cb1e2e87797d27e0041100b73ebf890dbafcff2e7e991d4593f5e8e309808
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.0"
|
||||||
|
firebase_auth_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_auth_platform_interface
|
||||||
|
sha256: c71517b3c78480be42789b05316a7692d69296c17848bd6a9e798300abae1ec7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.9"
|
||||||
|
firebase_auth_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_auth_web
|
||||||
|
sha256: "52b0224eb46b09f387e99710707be2d3f48da67c74fe14202e4b942cbe8ce9fd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5"
|
||||||
|
firebase_core:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_core
|
||||||
|
sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.7.0"
|
||||||
|
firebase_core_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_platform_interface
|
||||||
|
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.3"
|
||||||
|
firebase_core_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_web
|
||||||
|
sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.6.0"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -305,18 +385,10 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
font_awesome_flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: font_awesome_flutter
|
|
||||||
sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.0.0"
|
|
||||||
functions_client:
|
functions_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -685,14 +757,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.12.0"
|
version: "3.12.0"
|
||||||
pdf_widget_wrapper:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: pdf_widget_wrapper
|
|
||||||
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.4"
|
|
||||||
pdfx:
|
pdfx:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -805,14 +869,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "2.7.0"
|
||||||
printing:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: printing
|
|
||||||
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.14.3"
|
|
||||||
provider:
|
provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1051,7 +1107,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ dependencies:
|
|||||||
file_picker: ^11.0.2
|
file_picker: ^11.0.2
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_web_plugins:
|
|
||||||
sdk: flutter
|
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^9.1.1
|
flutter_bloc: ^9.1.1
|
||||||
@@ -33,9 +31,9 @@ dependencies:
|
|||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
pdf: ^3.12.0
|
pdf: ^3.12.0
|
||||||
universal_io: ^2.3.1
|
universal_io: ^2.3.1
|
||||||
url_launcher: ^6.3.2
|
firebase_core: ^4.7.0
|
||||||
printing: ^5.14.3
|
firebase_auth: ^6.4.0
|
||||||
font_awesome_flutter: ^11.0.0
|
cloud_firestore: ^6.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -49,4 +47,5 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/svg/
|
- assets/svg/
|
||||||
|
- assets/schedeRiparazione-1778021345.json
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/* /index.html 200
|
|
||||||
@@ -7,23 +7,29 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <app_links/app_links_plugin_c_api.h>
|
#include <app_links/app_links_plugin_c_api.h>
|
||||||
|
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <pdfx/pdfx_plugin.h>
|
#include <pdfx/pdfx_plugin.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <printing/printing_plugin.h>
|
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AppLinksPluginCApiRegisterWithRegistrar(
|
AppLinksPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||||
|
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
|
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||||
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
PdfxPluginRegisterWithRegistrar(
|
PdfxPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
PrintingPluginRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
app_links
|
app_links
|
||||||
|
cloud_firestore
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
|
firebase_auth
|
||||||
|
firebase_core
|
||||||
pdfx
|
pdfx
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
printing
|
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user