19 Commits

Author SHA1 Message Date
83988597d5 tasks 2026-05-28 13:55:28 +02:00
b298509178 a 2026-05-27 19:38:59 +02:00
b6e5f9acbe x 2026-05-27 16:00:50 +02:00
f6ecb33729 refactor: replace string literals with table constants in TaskRepository 2026-05-27 08:41:53 +02:00
9d796d6e41 boh 2026-05-26 19:31:25 +02:00
45455a16c4 w 2026-05-26 12:28:12 +02:00
2afe97c6db spostato aggiornamento tabella supabase sul worker del mac anche per FluxInstaller.exe
All checks were successful
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 3m20s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m44s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
2026-05-25 16:45:51 +02:00
4101b736e6 fix windows deployment
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m12s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m52s
2026-05-25 16:34:23 +02:00
b67354610d prova x sistemare pipeline windows
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m31s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m32s
2026-05-25 15:55:53 +02:00
b19c91a7dd refinements
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m56s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 5m9s
2026-05-25 14:29:48 +02:00
9b5d19b926 refinements 2026-05-25 12:49:04 +02:00
aad9a991c2 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m36s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 4m22s
2026-05-24 13:35:59 +02:00
7f0d18eed1 aggiorna link aggiornamenti
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m47s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:51:16 +02:00
879c848d77 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m13s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:42:11 +02:00
123c006a1e changed navigation 2026-05-24 10:25:16 +02:00
415811f592 app shell 2026-05-24 09:49:07 +02:00
31066a4d8f v
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m28s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 4m1s
2026-05-23 17:16:51 +02:00
b700c2de8d w
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m4s
Build and Release FLUX (Multi-Platform) / build-android (push) Failing after 15m38s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m4s
2026-05-23 16:50:56 +02:00
fda5b8fe2e v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m24s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m56s
2026-05-23 13:46:27 +02:00
46 changed files with 3409 additions and 729 deletions

View File

@@ -21,17 +21,17 @@ jobs:
- name: Build Flutter Windows
run: flutter build windows --release
- name: Compila Installer Inno Setup
- name: Build Windows Installer
run: |
$TagVersion = "${{ gitea.ref_name }}".Substring(1)
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=$TagVersion "win_installer.iss"
$TagVersion = "${{ github.ref_name }}".Substring(1)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
# Nel dubbio usiamo l'action per caricare l'asset
- name: Upload Windows Asset
uses: https://gitea.com/actions/release-action@main
with:
files: "build/windows/installer/FLUX_Setup_x64.exe"
api_key: ${{ secrets.GITEA_TOKEN }}
files: "build/windows/installer/FluxInstaller.exe"
api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Pulisci Workspace Windows
if: always()
@@ -54,14 +54,23 @@ jobs:
EOF
- name: Build Flutter APK
run: flutter build apk --release --split-per-abi
run: flutter build apk --release
# Carichiamo l'APK universale o quelli splittati nelle release di Gitea
- name: Upload Android Asset
uses: https://gitea.com/actions/release-action@main
with:
files: "build/app/outputs/flutter-apk/app-release.apk"
api_key: ${{ secrets.GITEA_TOKEN }}
api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Aggiorna Link Android su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
- name: Aggiorna Link Windows su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
# -----------------------------------------------------------------
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)

View File

@@ -16,6 +16,8 @@ class Tables {
static const String staffInStores = 'staff_in_stores';
static const String staffMembers = 'staff_members';
static const String stores = 'stores';
static const String tasks = 'tasks';
static const String taskAssignments = 'task_assignments';
static const String tickets = 'tickets';
static const String trackings = 'trackings';
}

View File

@@ -1,97 +1,370 @@
import 'package:flutter/material.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:go_router/go_router.dart';
// ==========================================
// 1. IL GUSCIO (QUELLO CHE PASSI AL ROUTER)
// ==========================================
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
// Calcoliamo l'indice attivo in base all'URL corrente!
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
if (location.startsWith('/master-data')) return 1;
if (location.startsWith('/settings')) return 2;
return 0; // Default: Dashboard
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/master-data');
break;
case 2:
context.go('/settings');
break;
}
}
@override
Widget build(BuildContext context) {
final currentIndex = _calculateSelectedIndex(context);
// Breakpoint: se lo schermo è più largo di 600px, usiamo la Rail laterale
final isDesktop = MediaQuery.sizeOf(context).width >= 600;
// Breakpoint a 900px: sotto è Mobile/Tablet (Drawer), sopra è Desktop (Sidebar)
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
final currentPath = GoRouterState.of(context).uri.path;
return Scaffold(
// Su mobile usiamo un'AppBar minimale per avere il bottone "Hamburger" nativo
appBar: isDesktop
? null
: AppBar(
title: const Text(
"FLUX",
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
),
drawer: isDesktop
? null
: Drawer(
// Su mobile inietta il menu qui!
child: AppMenu(currentPath: currentPath, isDrawer: true),
),
body: isDesktop
? Row(
children: [
NavigationRail(
selectedIndex: currentIndex,
onDestinationSelected: (index) =>
_onItemTapped(index, context),
labelType: NavigationRailLabelType.all,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text(context.l10n.commonDashboard),
),
NavigationRailDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: Text(context.l10n.commonMasterData),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text(context.l10n.commonSettings),
),
],
),
// Su desktop inietta il menu a sinistra!
AppMenu(currentPath: currentPath, isDrawer: false),
const VerticalDivider(thickness: 1, width: 1),
// Il contenuto della pagina
Expanded(child: child),
],
)
: child, // Su mobile il contenuto prende tutto lo schermo...
// ... e mettiamo la barra in basso!
bottomNavigationBar: isDesktop
? null
: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: context.l10n.commonDashboard,
),
NavigationDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: context.l10n.commonMasterData,
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: context.l10n.commonSettings,
),
],
),
: child, // Su mobile il child prende tutto lo schermo sotto l'AppBar
);
}
}
class AppMenu extends StatefulWidget {
final String currentPath; // Lo usiamo ancora per capire cosa accendere
final bool isDrawer;
const AppMenu({super.key, required this.currentPath, required this.isDrawer});
@override
State<AppMenu> createState() => _AppMenuState();
}
class _AppMenuState extends State<AppMenu> {
bool _isCollapsed = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bool effectivelyCollapsed = _isCollapsed && !widget.isDrawer;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: effectivelyCollapsed ? 72 : 260,
child: SafeArea(
child: Column(
children: [
// --- HEADER ---
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
alignment: effectivelyCollapsed
? Alignment.center
: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
if (!effectivelyCollapsed) ...[
const SizedBox(width: 12),
Text(
"FLUX",
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
],
),
),
// --- VOCI DI MENU ---
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: SizedBox(
width: effectivelyCollapsed ? 72 : 260,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
children: [
_buildRouteItem(
title: context.l10n.commonDashboard,
icon: Icons.dashboard_outlined,
routeName: Routes.home, // <--- Usiamo la tua costante!
pathToCheck:
'/', // Il path da controllare per colorarlo
isCollapsed: effectivelyCollapsed,
),
const SizedBox(height: 8),
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
_buildHierarchicalItem(
title: context.l10n.commonMasterData,
icon: Icons.folder_special_outlined,
basePathToCheck:
'/master-data', // Se il path inizia così, espandi
isCollapsed: effectivelyCollapsed,
subItems: [
_SubMenuItem(
"Clienti",
Routes.customers,
'/master-data/customers',
),
_SubMenuItem(
"Fornitori",
Routes.providers,
'/master-data/providers',
),
_SubMenuItem(
"Prodotti",
Routes.products,
'/master-data/products',
),
],
),
const SizedBox(height: 8),
_buildRouteItem(
title: context.l10n.commonSettings,
icon: Icons.settings_outlined,
routeName: Routes.settings,
pathToCheck: '/settings',
isCollapsed: effectivelyCollapsed,
),
],
),
),
),
),
// --- PULSANTE TOGGLE (Solo Desktop) ---
if (!widget.isDrawer)
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
icon: Icon(
_isCollapsed
? Icons.keyboard_double_arrow_right
: Icons.keyboard_double_arrow_left,
color: theme.iconTheme.color?.withValues(alpha: 0.5),
),
onPressed: () {
setState(() {
_isCollapsed = !_isCollapsed;
});
},
),
),
],
),
),
);
}
// ==========================================
// WIDGET HELPER AGGIORNATI
// ==========================================
Widget _buildRouteItem({
required String title,
required IconData icon,
required String routeName,
required String pathToCheck,
required bool isCollapsed,
}) {
final isSelected = widget.currentPath == pathToCheck;
final theme = Theme.of(context);
if (isCollapsed) {
return Tooltip(
message: title,
preferBelow: false,
child: InkWell(
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
borderRadius: BorderRadius.circular(8),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
),
);
}
return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
selected: isSelected,
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
);
}
Widget _buildHierarchicalItem({
required String title,
required IconData icon,
required String basePathToCheck,
required bool isCollapsed,
required List<_SubMenuItem> subItems,
}) {
final isSelected = widget.currentPath.startsWith(basePathToCheck);
final theme = Theme.of(context);
if (isCollapsed) {
return PopupMenuButton<String>(
tooltip: title,
offset: const Offset(60, 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (routeName) {
// Il routeName arriva dal value del menu
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
itemBuilder: (context) => subItems
.map(
(item) => PopupMenuItem(
value: item
.routeName, // Passiamo il nome della rotta (Routes.customers)
child: Text(
item.title,
style: TextStyle(
fontWeight: widget.currentPath == item.pathToCheck
? FontWeight.bold
: FontWeight.normal,
color: widget.currentPath == item.pathToCheck
? theme.colorScheme.primary
: null,
),
),
),
)
.toList(),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
);
}
return Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: isSelected,
maintainState: true,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
leading: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
children: subItems.map((item) {
final isSubSelected = widget.currentPath == item.pathToCheck;
return Padding(
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
child: ListTile(
dense: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.2,
),
selected: isSubSelected,
title: Text(
item.title,
style: TextStyle(
fontWeight: isSubSelected
? FontWeight.bold
: FontWeight.normal,
color: isSubSelected
? theme.colorScheme.primary
: theme.textTheme.bodyMedium?.color,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(item.routeName); // <--- goNamed!
},
),
);
}).toList(),
),
);
}
}
// Struttura dati per le voci dei sottomenu aggiornata
class _SubMenuItem {
final String title;
final String routeName; // Es: Routes.customers
final String pathToCheck; // Es: '/master-data/customers'
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
}

View File

@@ -18,6 +18,7 @@ import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_list_screen.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:flux/features/home/ui/home_screen.dart';
import 'package:flux/features/master_data/master_data_hub_content.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
@@ -27,8 +28,10 @@ import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.da
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:flux/features/notes/ui/notes_form_screen.dart';
@@ -42,6 +45,11 @@ import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/operations/ui/operation_list_screen.dart';
import 'package:flux/features/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/ui/task_form_screen.dart';
import 'package:flux/features/tasks/ui/task_list_screen.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
@@ -126,78 +134,102 @@ class AppRouter {
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
// ==========================================
// 1. DASHBOARD
// ==========================================
GoRoute(
path: '/',
name: Routes.home,
builder: (context, state) => const HomeScreen(),
builder: (context, state) {
return MultiBlocProvider(
providers: [
BlocProvider<DashboardTaskListCubit>(
create: (context) => DashboardTaskListCubit(),
),
],
child: HomeScreen(),
);
},
),
// ==========================================
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
// ==========================================
GoRoute(
path: '/master-data',
name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(),
routes: [
GoRoute(
path: 'products', // Diventa /master-data/products
path:
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
name: Routes.customers,
builder: (context, state) => const CustomersListScreen(),
),
GoRoute(
path: 'providers', // -> /master-data/providers
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
GoRoute(
path: 'products', // -> /master-data/products
name: Routes.products,
builder: (context, state) {
context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen();
},
),
GoRoute(
path: 'company-settings',
path: 'staff', // -> /master-data/staff
name: Routes.staff,
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path:
'stores', // Sistemata l'inversione path/name -> /master-data/stores
name: Routes.stores,
builder: (context, state) {
context.read<ProviderListCubit>().loadAllProviders();
context.read<StoreCubit>().loadStores();
return const StoresScreen();
},
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings
name: Routes.companySettings,
builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(),
),
),
GoRoute(
path: 'staff',
name: Routes.staff, // Diventa /master-data/staff
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: Routes.stores,
name: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: '/providers',
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
],
),
// ==========================================
// 3. IMPOSTAZIONI
// ==========================================
GoRoute(
path: '/settings',
name: Routes.settings,
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'themeSettings',
path: 'themeSettings', // -> /settings/themeSettings
name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(),
),
],
),
// ==========================================
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
// ==========================================
GoRoute(
path: '/operations',
name: Routes.operations,
builder: (context, state) => const OperationListScreen(),
),
GoRoute(
path: '/customers',
name: Routes.customers,
builder: (context, state) =>
const CustomersListScreen(), // O come si chiama il tuo widget della lista!
),
GoRoute(
path: '/tickets',
name: Routes.tickets,
@@ -208,6 +240,34 @@ class AppRouter {
name: Routes.notes,
builder: (context, state) => const NotesListScreen(),
),
GoRoute(
path: '/tasks',
name: Routes.tasks,
builder: (context, state) {
// 1. Recuperiamo lo stato della sessione per le dipendenze
final sessionState = context.read<SessionCubit>().state;
// Sicurezza: Se per qualche motivo non abbiamo l'azienda,
// qui potresti reindirizzare o gestire l'errore
final companyId = sessionState.company?.id;
if (companyId == null) {
return const Scaffold(
body: Center(child: Text("Errore: Azienda non trovata")),
);
}
// 2. Iniettiamo il Cubit con tutto ciò che gli serve
return BlocProvider(
create: (context) => TaskListCubit(
currentCompanyId: companyId,
currentStoreId: sessionState
.currentStore
?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store
),
child: const TaskListScreen(),
);
},
),
],
),
@@ -466,6 +526,39 @@ class AppRouter {
);
},
),
GoRoute(
path: '/tasks/form/:id',
name: Routes.taskForm,
builder: (context, state) {
final String pathId = state.pathParameters['id'] ?? 'new';
final TaskModel? task = state.extra as TaskModel?;
final String? realTaskId;
if (pathId == 'new') {
realTaskId = null;
} else if (task?.id != null) {
realTaskId = task!.id;
} else {
realTaskId = pathId;
}
final allStaffList = context.read<StaffCubit>().state.allStaff;
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<TaskFormCubit>(
create: (context) => TaskFormCubit(
globalStaff: allStaffList,
initialTask: task,
initialTaskId: realTaskId,
),
),
],
child: TaskFormScreen(),
);
},
),
],
);
}

View File

@@ -24,4 +24,6 @@ class Routes {
static const String ticketWorkspace = 'ticket-workspace';
static const String noteForm = 'note-form';
static const String notes = 'notes';
static const String tasks = 'tasks';
static const String taskForm = 'task-form';
}

View File

@@ -21,6 +21,7 @@ class FluxTextField extends StatefulWidget {
final TextCapitalization? textCapitalization;
final bool? autocorrect;
final bool? enabled;
final Iterable<String>? autofillHints;
const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno
@@ -41,6 +42,7 @@ class FluxTextField extends StatefulWidget {
this.textCapitalization,
this.autocorrect,
this.enabled = true,
this.autofillHints,
});
@override
@@ -118,6 +120,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
enabled: widget.enabled,
autofillHints: widget.autofillHints,
);
}
}

View File

@@ -15,7 +15,6 @@ enum Bucket {
class AttachmentsRepository {
final _supabase = Supabase.instance.client;
static const String _tableName = Tables.attachments;
/// Scarica i byte di un file direttamente da Supabase Storage
Future<Uint8List> downloadAttachmentBytes({
@@ -56,7 +55,7 @@ class AttachmentsRepository {
final columnName = _getColumnNameForParent(parentType);
return _supabase
.from(_tableName)
.from(Tables.attachments)
.stream(primaryKey: ['id'])
.eq(columnName, parentId)
.map(
@@ -141,7 +140,7 @@ class AttachmentsRepository {
insertData[columnName] = parentId;
// 6. Salviamo su Postgres
await _supabase.from(_tableName).insert(insertData);
await _supabase.from(Tables.attachments).insert(insertData);
} catch (e) {
throw Exception("Errore caricamento: $e");
}
@@ -179,12 +178,12 @@ class AttachmentsRepository {
// A. Ci sono ancora altre entità che usano questo file!
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
await _supabase
.from(_tableName)
.from(Tables.attachments)
.update({currentContextType.dbColumn: null})
.eq('id', file.id!);
} else {
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
await _supabase.from(_tableName).delete().eq('id', file.id!);
await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
if (file.storagePath != null) {
await _supabase.storage.from(bucket.value).remove([
@@ -202,7 +201,7 @@ class AttachmentsRepository {
Future<void> renameAttachment(String fileId, String newName) async {
try {
await _supabase
.from(_tableName)
.from(Tables.attachments)
.update({'name': newName})
.eq('id', fileId);
} catch (e) {
@@ -219,7 +218,7 @@ class AttachmentsRepository {
try {
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
await _supabase
.from(_tableName)
.from(Tables.attachments)
.update({targetType.dbColumn: targetId})
.eq('id', fileId);
} catch (e) {

View File

@@ -16,7 +16,8 @@ class AuthCubit extends Cubit<AuthState> {
emit(state.copyWith(isLoginMode: !state.isLoginMode));
}
Future<void> submitAuth(String email, String password) async {
Future<bool> submitAuth(String email, String password) async {
// <-- Modificato in bool
// Partiamo puliti: via vecchi messaggi ed errori
emit(state.copyWith(status: AuthStatus.loading));
@@ -27,9 +28,17 @@ class AuthCubit extends Cubit<AuthState> {
email: email,
password: password,
);
// NESSUN EMIT DI SUCCESS!
// Supabase lancerà l'evento 'signedIn', il SessionCubit lo catturerà
// e il GoRouter ci cambierà pagina. Noi stiamo a guardare il caricamento.
// Il login è andato a buon fine!
emit(
AuthState(
status: AuthStatus.initial,
isLoginMode: true,
errorMessage: null,
infoMessage: null,
),
);
return true;
} else {
// --- LOGICA SIGNUP ---
final AuthResponse res = await _supabase.auth.signUp(
@@ -38,7 +47,6 @@ class AuthCubit extends Cubit<AuthState> {
);
if (res.session == null) {
// Caso: Conferma Email attivata su Supabase
emit(
state.copyWith(
status: AuthStatus.initial,
@@ -48,16 +56,24 @@ class AuthCubit extends Cubit<AuthState> {
),
);
} else {
// Caso: Autologin post-registrazione (Conferma email disattivata)
// 1. Fermiamo il frullino!
emit(state.copyWith(status: AuthStatus.initial));
// 2. Svegliamo il SessionCubit!
GetIt.I<SessionCubit>().initializeSession();
}
// Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit.
// Anche la registrazione è andata a buon fine!
emit(
AuthState(
status: AuthStatus.initial,
isLoginMode: true,
errorMessage: null,
infoMessage: null,
),
);
return true;
}
} on AuthException catch (e) {
emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message));
return false; // <-- Il login è fallito
} catch (e) {
emit(
state.copyWith(
@@ -65,6 +81,7 @@ class AuthCubit extends Cubit<AuthState> {
errorMessage: "Errore imprevisto: $e",
),
);
return false; // <-- Il login è fallito
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
@@ -24,14 +25,18 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
void _submit() {
void _submit() async {
// Chiudiamo la tastiera per fare pulizia a schermo
FocusScope.of(context).unfocus();
context.read<AuthCubit>().submitAuth(
final isSuccess = await context.read<AuthCubit>().submitAuth(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (isSuccess) {
TextInput.finishAutofillContext();
}
}
@override
@@ -69,125 +74,133 @@ class _AuthScreenState extends State<AuthScreen> {
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- LOGO FLUX ---
const FluxLogoAuto(height: 80),
const SizedBox(height: 60),
child: AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- LOGO FLUX ---
const FluxLogoAuto(height: 80),
const SizedBox(height: 60),
// --- TITOLO DINAMICO ---
Text(
state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle(
color: context.primaryText,
fontSize: 24,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
// --- TITOLO DINAMICO ---
Text(
state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle(
color: context.primaryText,
fontSize: 24,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
),
),
),
const SizedBox(height: 8),
Text(
state.isLoginMode
? context.l10n.authScreenLoginToManageYourBusiness
: context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText),
),
const SizedBox(height: 40),
const SizedBox(height: 8),
Text(
state.isLoginMode
? context.l10n.authScreenLoginToManageYourBusiness
: context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText),
),
const SizedBox(height: 40),
// --- CAMPI INPUT ---
FluxTextField(
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 20),
FluxTextField(
label: 'Password',
icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
// --- CAMPI INPUT ---
FluxTextField(
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
),
const SizedBox(height: 20),
FluxTextField(
label: 'Password',
icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
const SizedBox(height: 40),
const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
),
),
// --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24),
TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
),
// --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24),
TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () => context
.read<AuthCubit>()
.requestPasswordReset(_emailController.text.trim()),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
],
),
),
),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () =>
context.read<AuthCubit>().requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
],
],
],
),
),
),
),

View File

@@ -0,0 +1,71 @@
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/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
part 'dashboard_task_list_state.dart';
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
RealtimeChannel? _taskChannel;
DashboardTaskListCubit() : super(DashboardTaskListState());
void startListening({required String staffId}) async {
emit(state.copyWith(status: DashboardTaskListStatus.loading));
await _loadTasks(staffId: staffId);
_taskChannel?.unsubscribe();
_taskChannel = _supabase
.channel('public:tasks_staff_$staffId')
.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: 'tasks',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'staff_id',
value: staffId,
),
callback: (payload) {
_loadTasks(staffId: staffId);
},
);
_taskChannel?.subscribe();
}
Future<void> _loadTasks({required String staffId}) async {
try {
final tasks = await _repository.getTasks(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
staffId: staffId,
statuses: [TaskStatus.open, TaskStatus.inProgress],
);
emit(
state.copyWith(
status: DashboardTaskListStatus.success,
tasks: tasks,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DashboardTaskListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
@override
Future<void> close() {
_taskChannel?.unsubscribe();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_task_list_cubit.dart';
enum DashboardTaskListStatus { initial, loading, success, failure }
class DashboardTaskListState extends Equatable {
final DashboardTaskListStatus status;
final List<TaskModel> tasks;
final String? errorMessage;
const DashboardTaskListState({
this.status = DashboardTaskListStatus.initial,
this.tasks = const [],
this.errorMessage,
});
DashboardTaskListState copyWith({
DashboardTaskListStatus? status,
List<TaskModel>? tasks,
String? errorMessage,
}) {
return DashboardTaskListState(
status: status ?? this.status,
tasks: tasks ?? this.tasks,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, tasks, errorMessage];
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:go_router/go_router.dart';
import 'package:flux/features/tasks/models/task_status.dart';
class DashboardTasksCard extends StatelessWidget {
const DashboardTasksCard({super.key});
@override
Widget build(BuildContext context) {
// Recuperiamo lo staff (o l'utente) loggato
// Adatta il getter in base a come è strutturato il tuo SessionState
final currentStaffId = context
.read<SessionCubit>()
.state
.currentStaffMember
?.id;
if (currentStaffId == null) {
return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto
}
return BlocProvider(
create: (context) =>
DashboardTaskListCubit()..startListening(staffId: currentStaffId),
child: const _DashboardTasksCardContent(),
);
}
}
class _DashboardTasksCardContent extends StatelessWidget {
const _DashboardTasksCardContent();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color =
Colors.orange; // Colore arancione per distinguerla dai Ticket blu
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: InkWell(
onTap: () => context.pushNamed(
Routes.tasks,
), // Porta alla lista completa (TaskListScreen)
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.assignment_outlined, // Icona a tema ToDo
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"I Miei Task",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child: BlocBuilder<DashboardTaskListCubit, DashboardTaskListState>(
builder: (context, state) {
if (state.status == DashboardTaskListStatus.loading ||
state.status == DashboardTaskListStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == DashboardTaskListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento ${state.errorMessage}",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tasks.isEmpty) {
return Center(
child: Text(
"Nessun task in sospeso. Ottimo lavoro!",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tasks.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final task = state.tasks[index];
// Definisci il colore in base allo stato del task
final statusColor = task.status == TaskStatus.inProgress
? Colors.blue
: Colors.grey.shade400;
// Formattiamo la data (o indichiamo se non c'è)
final dueDateString = task.dueDate != null
? "${task.dueDate!.day}/${task.dueDate!.month}"
: "Nessuna";
// Controllo Ninja: Il task è già scaduto rispetto a oggi?
final isOverdue =
task.dueDate != null &&
task.dueDate!.isBefore(DateTime.now());
return InkWell(
onTap: () => context.pushNamed(
Routes.taskForm,
extra:
task, // Passiamo direttamente il modello intero se il tuo router lo accetta!
pathParameters: {'id': task.id ?? 'new'},
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height: 30,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Expanded(
flex: 7,
child: Text(
task.title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerRight,
child: Text(
dueDateString,
style: TextStyle(
color: isOverdue
? theme.colorScheme.error
: context.secondaryText,
fontSize: 12,
fontWeight: isOverdue
? FontWeight.bold
: FontWeight.normal,
),
),
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -53,6 +53,5 @@ class LatestStoreTicketsBloc
);
}
});
// TODO: implement event handlers
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart';
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
import 'package:flux/features/home/ui/quick_actions_widget.dart';
@@ -75,12 +76,7 @@ class HomeScreen extends StatelessWidget {
context: context,
),
DashboardNotesWidget(),
_buildDashboardWidget(
title: context.l10n.homeMyTasks,
icon: Icons.check_box_outlined,
color: Colors.green,
context: context,
),
DashboardTasksCard(),
]),
),
),
@@ -238,7 +234,7 @@ class HomeScreen extends StatelessWidget {
label: context.l10n.commonTask,
color: Colors.teal,
onTap: () {
// TODO: Quando faremo i task
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
},
),
],

View File

@@ -12,7 +12,22 @@ class StaffCubit extends Cubit<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
StaffCubit() : super(const StaffState());
StaffCubit() : super(const StaffState()) {
init();
}
Future<void> init() async {
emit(state.copyWith(status: StaffStatus.loading, error: null));
try {
final allStaff = await _repository.getStaffMembers(
_sessionCubit.state.company!.id!,
);
emit(state.copyWith(status: StaffStatus.success, allStaff: allStaff));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
}
}
// Carica tutto lo staff della compagnia
Future<void> loadAllStaff() async {

View File

@@ -13,7 +13,12 @@ class StaffRepository {
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
final response = await _supabase
.from(Tables.staffMembers)
.select()
.select('''
*,
store_assignments:${Tables.staffInStores} (
${Tables.stores}(*)
)
''')
.eq('company_id', companyId)
.order('name', ascending: true);
@@ -24,7 +29,12 @@ class StaffRepository {
try {
final response = await _supabase
.from(Tables.staffMembers)
.select()
.select('''
*,
store_assignments:${Tables.staffInStores} (
${Tables.stores}(*)
)
''')
.eq('id', staffId)
.single();
return StaffMemberModel.fromMap(response);

View File

@@ -1,4 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
// L'Enum magico e blindato per il sistema
enum SystemRole {
@@ -26,6 +28,8 @@ class StaffMemberModel extends Equatable {
final SystemRole systemRole;
final bool isActive;
final bool hasJoined;
final List<String> assignedStoreIds;
final List<StoreModel> assignedStores;
const StaffMemberModel({
this.id,
@@ -38,6 +42,8 @@ class StaffMemberModel extends Equatable {
this.systemRole = SystemRole.user,
this.isActive = true,
this.hasJoined = false,
this.assignedStoreIds = const [],
this.assignedStores = const [],
});
StaffMemberModel copyWith({
@@ -52,6 +58,8 @@ class StaffMemberModel extends Equatable {
SystemRole? systemRole,
bool? isActive,
bool? hasJoined,
List<String>? assignedStoreIds,
List<StoreModel>? assignedStores,
}) {
return StaffMemberModel(
id: id ?? this.id,
@@ -64,6 +72,8 @@ class StaffMemberModel extends Equatable {
systemRole: systemRole ?? this.systemRole,
isActive: isActive ?? this.isActive,
hasJoined: hasJoined ?? this.hasJoined,
assignedStoreIds: assignedStoreIds ?? this.assignedStoreIds,
assignedStores: assignedStores ?? this.assignedStores,
);
}
@@ -79,6 +89,24 @@ class StaffMemberModel extends Equatable {
}
factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedStoreIds =
map['assigned_store_ids'] != null
? List<String>.from(map['assigned_store_ids'])
: [];
// 2. Mappiamo il JOIN degli store, se presente
List<StoreModel> storeList = [];
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
if (map['store_assignments'] != null) {
storeList = (map['store_assignments'] as List)
.map((a) => a[Tables.stores])
.where((s) => s != null)
.map((s) => StoreModel.fromMap(s))
.toList();
}
return StaffMemberModel(
id: map['id'] as String?,
companyId: map['company_id'] ?? '',
@@ -90,6 +118,8 @@ class StaffMemberModel extends Equatable {
systemRole: SystemRole.fromString(map['system_role']),
isActive: map['is_active'] ?? true,
hasJoined: map['has_joined'] ?? false,
assignedStoreIds: parsedAssignedStoreIds,
assignedStores: storeList,
);
}
@@ -120,5 +150,7 @@ class StaffMemberModel extends Equatable {
systemRole,
isActive,
hasJoined,
assignedStoreIds,
assignedStores,
];
}

View File

@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_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/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso
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/store/bloc/store_cubit.dart';
import 'package:get_it/get_it.dart';
@@ -17,18 +17,16 @@ class StaffScreen extends StatefulWidget {
class _StaffScreenState extends State<StaffScreen> {
String? _selectedStoreId;
bool _showAllCompanyStaff = true; // Partiamo con la vista globale
bool _showAllCompanyStaff = true;
@override
void initState() {
super.initState();
// Carichiamo subito tutto
context.read<StaffCubit>().loadAllStaff();
}
@override
Widget build(BuildContext context) {
// 1. Peschiamo chi siamo noi e che poteri abbiamo
final myRole = context
.read<SessionCubit>()
.state
@@ -36,12 +34,12 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
return Scaffold(
backgroundColor: context.background,
appBar: AppBar(
title: const Text("Anagrafica Personale"),
actions: [
// Toggle per vista Azienda / Negozio
Padding(
padding: const EdgeInsets.only(right: 16),
child: FilterChip(
@@ -66,7 +64,7 @@ class _StaffScreenState extends State<StaffScreen> {
},
child: Column(
children: [
// --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') ---
// BARRA FILTRO NEGOZIO
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _showAllCompanyStaff ? 0 : 80,
@@ -75,7 +73,7 @@ class _StaffScreenState extends State<StaffScreen> {
: _buildStoreSelector(),
),
// --- LISTA PERSONALE ---
// LISTA PERSONALE
Expanded(
child: BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
@@ -87,17 +85,14 @@ class _StaffScreenState extends State<StaffScreen> {
return const Center(child: CircularProgressIndicator());
}
if (list.isEmpty) {
return _buildEmptyState();
}
if (list.isEmpty) return _buildEmptyState();
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final member = list[index];
return _buildStaffCard(member);
return _buildStaffCard(list[index]);
},
);
},
@@ -118,7 +113,6 @@ class _StaffScreenState extends State<StaffScreen> {
Widget _buildStoreSelector() {
return BlocBuilder<StoreCubit, StoreState>(
// Assumendo tu abbia uno StoreCubit
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -146,6 +140,8 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
final hasEmail = member.email != null && member.email!.trim().isNotEmpty;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -156,7 +152,10 @@ class _StaffScreenState extends State<StaffScreen> {
contentPadding: const EdgeInsets.all(12),
leading: CircleAvatar(
backgroundColor: context.accent.withValues(alpha: 0.1),
child: Text(member.name[0], style: TextStyle(color: context.accent)),
child: Text(
member.name[0].toUpperCase(),
style: TextStyle(color: context.accent),
),
),
title: Text(
member.name,
@@ -165,55 +164,65 @@ class _StaffScreenState extends State<StaffScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (member.email != null && member.email!.isNotEmpty)
Text(member.email!),
if (hasEmail) Text(member.email!),
Text(
member.phoneNumber != null && member.phoneNumber!.isNotEmpty
member.phoneNumber != null &&
member.phoneNumber!.trim().isNotEmpty
? member.phoneNumber!
: "Nessun telefono",
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[
Text('Qualifica: ${member.jobTitle!}'),
const SizedBox(width: 8),
],
if (canManageStaff) ...[
const SizedBox(width: 8),
if (!member.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
if (member.jobTitle != null &&
member.jobTitle!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Qualifica: ${member.jobTitle!}',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w500,
),
),
],
],
),
// MODIFICA UX: Menu a tendina per le azioni (Salva spazio e previene overflow)
trailing: canManageStaff && hasEmail
? PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'invite_reset') {
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Operazione richiesta, controlla l\'email!',
),
),
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'invite_reset',
child: Row(
children: [
Icon(
!member.hasJoined ? Icons.send : Icons.lock_reset,
size: 20,
),
const SizedBox(width: 12),
Text(
!member.hasJoined
? "Re-invia Invito"
: "Reset Password",
),
],
),
),
],
)
: null,
onTap: () =>
canManageStaff ? _openStaffForm(context, member: member) : null,
),
@@ -226,7 +235,6 @@ class _StaffScreenState extends State<StaffScreen> {
final phoneController = TextEditingController(text: member?.phoneNumber);
final jobTitleController = TextEditingController(text: member?.jobTitle);
// Variabili di stato per il BottomSheet
SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
List<String> tempSelectedStores =
context
@@ -263,7 +271,7 @@ class _StaffScreenState extends State<StaffScreen> {
children: [
Text(
member == null
? "Invita Collaboratore" // Cambiato il titolo per chiarezza!
? "Invita Collaboratore"
: "Modifica Collaboratore",
style: const TextStyle(
fontSize: 20,
@@ -279,16 +287,13 @@ class _StaffScreenState extends State<StaffScreen> {
),
const SizedBox(height: 16),
// Reso visivamente obbligatorio se è un nuovo utente
FluxTextField(
controller: emailController,
label: member == null
? "Email (Obbligatoria per invito)*"
: "Email",
icon: Icons.email,
enabled:
member ==
null, // UX: Di solito l'email non si cambia dopo l'invito
enabled: member == null,
),
const SizedBox(height: 16),
@@ -299,7 +304,6 @@ class _StaffScreenState extends State<StaffScreen> {
),
const SizedBox(height: 16),
// --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE ---
Row(
children: [
Expanded(
@@ -382,7 +386,6 @@ class _StaffScreenState extends State<StaffScreen> {
height: 50,
child: ElevatedButton(
onPressed: () {
// Validazione di base per i nuovi inviti
if (member == null &&
emailController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -396,7 +399,7 @@ class _StaffScreenState extends State<StaffScreen> {
}
final updatedMember = StaffMemberModel(
id: member?.id, // Sarà null se è nuovo
id: member?.id,
name: nameController.text.trim(),
email: emailController.text.trim(),
phoneNumber: phoneController.text.trim(),
@@ -410,17 +413,12 @@ class _StaffScreenState extends State<StaffScreen> {
userId: GetIt.I.get<SessionCubit>().state.user!.id,
);
// --- IL BIVIO LOGICO MAGICO ---
if (member == null) {
// 1. UTENTE NUOVO -> Chiamiamo la Edge Function
// (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit
// perché l'ID del database viene generato DOPO che l'Edge Function ha finito)
context.read<StaffCubit>().inviteStaffMember(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
);
} else {
// 2. UTENTE ESISTENTE -> Modifica classica
context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
@@ -434,32 +432,6 @@ class _StaffScreenState extends State<StaffScreen> {
),
),
),
/* const SizedBox(height: 16),
if (!member!.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
), */
],
),
),

View File

@@ -54,6 +54,8 @@ class _StoreCardState extends State<StoreCard> {
),
title: Text(
widget.store.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
@@ -65,44 +67,43 @@ class _StoreCardState extends State<StoreCard> {
// context.read<StoreBloc>().add(ToggleStoreStatus(store.id, val));
},
),
onTap: () => _openStoreForm(context, store: widget.store),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
final staffCount =
storeState.staffByStore[widget.store.id]?.length ?? 0;
return Row(
children: [
ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("$staffCount Dipendenti"),
onPressed: () => _manageStoreStaff(widget.store),
),
const SizedBox(width: 16),
ActionChip(
avatar: const Icon(Icons.handshake, size: 16),
label: Text(
"${widget.store.associatedProviders.length} Providers",
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
final staffCount =
storeState.staffByStore[widget.store.id]?.length ?? 0;
return Row(
children: [
ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("$staffCount Dipendenti"),
onPressed: () => _manageStoreStaff(widget.store),
),
onPressed: () => _manageStoreProviders(widget.store),
),
],
);
},
),
const SizedBox(width: 16),
TextButton.icon(
onPressed: () => _openStoreForm(context, store: widget.store),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Modifica"),
),
],
const SizedBox(width: 16),
ActionChip(
avatar: const Icon(Icons.handshake, size: 16),
label: Text(
"${widget.store.associatedProviders.length} Providers",
),
onPressed: () =>
_manageStoreProviders(widget.store),
),
],
);
},
),
],
),
),
),
],

View File

@@ -105,7 +105,11 @@ class DashboardNotesWidget extends StatelessWidget {
return GestureDetector(
onTap: () {
// Vai al form di dettaglio passando l'ID o l'oggetto
context.push('/notes/edit/${note.id}');
context.pushNamed(
Routes.noteForm,
pathParameters: {'id': note.id!},
extra: note,
);
},
child: Container(
width: 140,

View File

@@ -248,89 +248,121 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
Row(
children: [
// Tavolozza Colori
Expanded(
child: SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _noteColors.length,
itemBuilder: (context, index) {
final colorHex = _noteColors[index];
final isSelected = _selectedColor == colorHex;
final c = Color(
int.parse(
'FF${colorHex.replaceAll('#', '')}',
radix: 16,
),
);
LayoutBuilder(
builder: (context, constraints) {
// 1. Capiamo quanto spazio reale ha la finestra in questo momento
final isNarrow = constraints.maxWidth < 500;
return GestureDetector(
onTap: () {
setState(() => _selectedColor = colorHex);
_triggerAutoSave();
},
child: Container(
margin: const EdgeInsets.only(right: 12),
width: 40,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.black54
: Colors.black12,
width: isSelected ? 3 : 1,
),
// 2. Adattiamo la dimensione dei cerchi
final double circleSize = isNarrow ? 32.0 : 40.0;
// -- PREPARIAMO IL BLOCCO COLORI --
final colorPalette = SizedBox(
height: circleSize,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _noteColors.length,
itemBuilder: (context, index) {
final colorHex = _noteColors[index];
final isSelected = _selectedColor == colorHex;
final c = Color(
int.parse(
'FF${colorHex.replaceAll('#', '')}',
radix: 16,
),
);
return GestureDetector(
onTap: () {
setState(() => _selectedColor = colorHex);
_triggerAutoSave();
},
child: Container(
margin: const EdgeInsets.only(right: 12),
width: circleSize,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.black54
: Colors.black12,
width: isSelected ? 3 : 1,
),
child: isSelected
? const Icon(
Icons.check,
color: Colors.black54,
size: 20,
)
: null,
),
);
child: isSelected
? Icon(
Icons.check,
color: Colors.black54,
size: isNarrow ? 16 : 20,
)
: null,
),
);
},
),
);
// -- PREPARIAMO IL BLOCCO AZIONI --
final actionButtons = Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.black87,
),
tooltip: 'Elimina',
onPressed: _deleteNote,
),
IconButton(
icon: Icon(
_isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: Colors.black87,
),
tooltip: _isPinned
? 'Rimuovi in alto'
: 'Fissa in alto',
onPressed: () {
setState(() => _isPinned = !_isPinned);
_triggerAutoSave();
},
),
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(
Icons.ios_share,
color: Colors.black87,
),
tooltip: 'Esporta',
onPressed: _exportNote,
),
],
);
// Azioni spostate dentro la nota!
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.black87,
),
tooltip: 'Elimina',
onPressed: _deleteNote,
),
IconButton(
icon: Icon(
_isPinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.black87,
),
tooltip: _isPinned
? 'Rimuovi in alto'
: 'Fissa in alto',
onPressed: () {
setState(() => _isPinned = !_isPinned);
_triggerAutoSave();
},
),
IconButton(
icon: const Icon(
Icons.ios_share,
color: Colors.black87,
),
tooltip: 'Esporta',
onPressed: _exportNote,
),
],
// 3. DECIDIAMO IL LAYOUT FINALE IN BASE ALLO SPAZIO REALE
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
actionButtons,
const SizedBox(height: 8),
colorPalette,
],
);
}
// Layout "Largo" (Finestra intera)
return Row(
children: [
Expanded(child: colorPalette),
const SizedBox(width: 16),
actionButtons,
],
);
},
),
const SizedBox(height: 32),

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/models/customer_model.dart';
@@ -184,10 +185,11 @@ class OperationModel extends Equatable {
// I campi relazionali nullabili restano rigorosamente null!
providerId: map['provider_id'] as String?,
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
providerDisplayName: (map[Tables.providers]?['name'] as String?)
?.myFormat(),
modelId: map['model_id'] as String?,
modelDisplayName: (map['model']?['name_with_brand'] as String?)
modelDisplayName: (map[Tables.models]?['name_with_brand'] as String?)
?.myFormat(),
description: map['description'] as String?,
@@ -202,25 +204,26 @@ class OperationModel extends Equatable {
storeId:
map['store_id'] as String? ??
'', // Questo è non-nullable nella tua classe
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
storeDisplayName: (map[Tables.stores]?['name'] as String?)?.myFormat(),
quantity: map['quantity'] is int
? map['quantity']
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
staffId: map['staff_id'] as String?,
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
staffDisplayName: (map[Tables.staffMembers]?['name'] as String?)
?.myFormat(),
lastCampaignId: map['last_campaign_id'] as String?,
status: OperationStatus.fromString(map['status'] ?? 'draft'),
customerId: map['customer_id'] as String?,
customer: map['customer'] != null
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
customer: map[Tables.customers] != null
? CustomerModel.fromMap(map[Tables.customers] as Map<String, dynamic>)
: null,
attachments:
(map['attachment'] as List?)
(map[Tables.attachments] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],

View File

@@ -483,36 +483,39 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
icon: Icons.design_services,
themeColor: Colors.deepOrange,
children: [
Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.operation.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.operation.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.operation.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.operation.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
),
),
const Divider(height: 32),
Wrap(

View File

@@ -8,151 +8,163 @@ class DocumentSequenceSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final year = DateTime.now().year;
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
builder: (context, state) {
if (state.status == DocumentSequenceStatus.loading) {
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),
),
),
// Invece di mappare state.sequences, mappiamo i documenti supportati
...DocumentType.values.map((docType) {
// Cerchiamo se c'è già una configurazione nello stato per questo documento
final existingList = state.sequences
.where((s) => s.docType == docType.name)
.toList();
final existingSeq = existingList.isNotEmpty
? existingList.first
: null;
// Se esiste usiamo i suoi valori, altrimenti i default
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
final nextValue = existingSeq?.nextValue ?? 1;
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
final preview =
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
docType.label,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
initialValue: prefix,
decoration: const InputDecoration(
labelText: 'Prefisso',
hintText: 'es. TCK',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
prefix: val,
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: TextFormField(
initialValue: nextValue.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Prossimo Numero',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
nextValue: int.tryParse(val) ?? 1,
),
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors
.grey
.shade100, // Se hai un tema scuro potresti voler usare Theme.of(context).colorScheme.surfaceContainer
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, // Idem per la dark mode
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"),
),
),
],
return LayoutBuilder(
builder: ((context, constraints) {
final isLargeScreen = constraints.maxWidth >= 600;
return _buildMainContent(
state: state,
isLargeScreen: isLargeScreen,
context: context,
);
}),
);
},
);
}
Widget _buildMainContent({
required BuildContext context,
required DocumentSequenceState state,
required bool isLargeScreen,
}) {
final year = DateTime.now().year;
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),
),
),
// Invece di mappare state.sequences, mappiamo i documenti supportati
...DocumentType.values.map((docType) {
// Cerchiamo se c'è già una configurazione nello stato per questo documento
final existingList = state.sequences
.where((s) => s.docType == docType.name)
.toList();
final existingSeq = existingList.isNotEmpty
? existingList.first
: null;
// Se esiste usiamo i suoi valori, altrimenti i default
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
final nextValue = existingSeq?.nextValue ?? 1;
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
final preview =
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
docType.label,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
initialValue: prefix,
decoration: const InputDecoration(
labelText: 'Prefisso',
hintText: 'es. TCK',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(docType.name, prefix: val),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: TextFormField(
initialValue: nextValue.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Prossimo Numero',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
nextValue: int.tryParse(val) ?? 1,
),
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
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, // Idem per la dark mode
fontSize: 12,
),
),
Flexible(
child: 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"),
),
),
],
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/settings/blocs/settings_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
class SettingsScreen extends StatelessWidget {
@@ -17,20 +18,55 @@ class SettingsScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_settingsSection('Account', [
_settingsSection('Azienda', [
_settingsTile(
icon: Icons.person,
title: 'Profilo Utente',
subtitle: 'Configura i tuoi dati',
title: 'Impostazioni Azienda',
icon: Icons.business,
subtitle: 'Configura i dati aziendali',
context: context,
onTap: () {},
onTap: () => context.pushNamed(Routes.companySettings),
),
const Divider(height: 30),
_settingsTile(
title: 'Impostazione Negozi',
icon: Icons.store,
subtitle: 'Crea o configura i negozi',
context: context,
onTap: () => context.pushNamed(Routes.stores),
),
const Divider(height: 30),
_settingsTile(
title: 'Impostazione Staff / Utenti',
icon: Icons.group,
subtitle:
'Configura i membri dei negozi o invita nuovi utenti in azienda',
context: context,
onTap: () => context.pushNamed(Routes.staff),
),
]),
const SizedBox(height: 20),
_settingsSection('Applicazione', [
BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, state) => CheckboxListTile(
value: state.isSingleUserMode,
title: const Text(
'Modalità utente singolo (dispositivo personale)',
title: Row(
children: [
const Icon(Icons.person, color: FluxColors.primaryBlue),
const SizedBox(width: 12),
Flexible(
child: Text(
'Modalità utente singolo (dispositivo personale)',
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(left: 36),
child: Text(
'Utente ${GetIt.I.get<SessionCubit>().state.currentStaffMember?.name ?? 'Nessuno'} selezionato automaticamente',
),
),
onChanged: (value) {
context.read<SessionCubit>().setIsSingleUserMode(value!);
@@ -39,16 +75,6 @@ class SettingsScreen extends StatelessWidget {
),
),
const Divider(height: 30),
_settingsTile(
title: 'Impostazioni Azienda',
icon: Icons.business,
subtitle: 'Configura i dati aziendali',
context: context,
onTap: () => context.pushNamed(Routes.companySettings),
),
]),
const SizedBox(height: 16),
_settingsSection('Applicazione', [
_settingsTile(
icon: Icons.dark_mode,
title: 'Tema (FLUX Dark)',

View File

@@ -0,0 +1,211 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:get_it/get_it.dart';
part 'task_form_state.dart';
class TaskFormCubit extends Cubit<TaskFormState> {
final TaskRepository _taskRepository = GetIt.I.get<TaskRepository>();
final List<StaffMemberModel> _globalStaff;
final String currentCompanyId = GetIt.I<SessionCubit>().state.company!.id!;
final String? currentStoreId = GetIt.I<SessionCubit>().state.currentStore?.id;
TaskFormCubit({
required List<StaffMemberModel> globalStaff,
TaskModel? initialTask, // Arriva dalla navigazione interna (extra)
String? initialTaskId, // Arriva dal Deep Link (parametro URL)
}) : _globalStaff = globalStaff,
super(const TaskFormState()) {
_initForm(initialTask, initialTaskId);
}
Future<void> _initForm(TaskModel? task, String? taskId) async {
// 1. Mettiamo subito il form in caricamento
emit(state.copyWith(status: TaskFormStatus.loading));
TaskModel? taskToLoad = task;
// 2. SCENARIO DEEP LINK: Non abbiamo l'oggetto, ma abbiamo un ID valido
if (taskToLoad == null && taskId != null && taskId != 'new') {
try {
taskToLoad = await _taskRepository.getTaskById(taskId);
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.failure,
errorMessage: 'Impossibile caricare il task dal link: $e',
),
);
return; // Ci fermiamo qui
}
}
// 3. Popoliamo lo stato con i dati (sia che arrivino dall'extra, sia dal DB, sia nulli)
final isGlobalMode = taskToLoad != null
? taskToLoad.storeId == null
: currentStoreId == null;
final existingStaffIds =
taskToLoad?.assignedToStaff.map((s) => s.id!).toList() ??
taskToLoad?.assignedToIds ??
[];
emit(
state.copyWith(
id: taskToLoad?.id,
title: taskToLoad?.title ?? '',
description: taskToLoad?.description ?? '',
dueDate: taskToLoad?.dueDate,
taskStatus: taskToLoad?.status ?? TaskStatus.open,
isGlobal: isGlobalMode,
selectedStaffIds: existingStaffIds,
status: TaskFormStatus.initial, // Caricamento finito, form pronto!
),
);
_updateStaffScope(isGlobalMode);
}
// --- 2. SWITCH SCOPE E RAGGRUPPAMENTO ---
void toggleGlobalScope(bool isGlobal) {
emit(
state.copyWith(
isGlobal: isGlobal,
selectedStaffIds: [], // Resettiamo la selezione se si cambia scope
),
);
_updateStaffScope(isGlobal);
}
void _updateStaffScope(bool isGlobal) {
// 1. Filtriamo in memoria: cerchiamo nell'array degli ID!
final filteredStaff = isGlobal
? _globalStaff
: _globalStaff
.where((s) => s.assignedStoreIds.contains(currentStoreId))
.toList();
// 2. Raggruppamento M2M (Ciclo manuale)
final Map<String, List<StaffMemberModel>> groupedStaff = {};
for (final staff in filteredStaff) {
// Se non ha nessun negozio assegnato, finisce in Direzione
if (staff.assignedStores.isEmpty) {
groupedStaff.putIfAbsent('Direzione / HQ', () => []).add(staff);
} else {
// Se ha più negozi, clona la sua presenza in ogni gruppo!
for (final store in staff.assignedStores) {
final storeName = store.name;
groupedStaff.putIfAbsent(storeName, () => []).add(staff);
}
}
}
// 3. Emettiamo il nuovo stato all'istante
emit(
state.copyWith(
status: TaskFormStatus.initial,
groupedAvailableStaff: groupedStaff,
),
);
}
// --- 3. SELEZIONE AVANZATA (SINGOLA E PER NEGOZIO) ---
void toggleStaffSelection(String staffId) {
final currentList = List<String>.from(state.selectedStaffIds);
if (currentList.contains(staffId)) {
currentList.remove(staffId);
} else {
currentList.add(staffId);
}
emit(state.copyWith(selectedStaffIds: currentList));
}
void toggleStoreSelection(String storeName, bool selectAll) {
// Recupera tutti i membri di quel gruppo
final staffInStore = state.groupedAvailableStaff[storeName] ?? [];
final idsInStore = staffInStore.map((s) => s.id!).toList();
final currentSelection = Set<String>.from(state.selectedStaffIds);
if (selectAll) {
currentSelection.addAll(idsInStore);
} else {
currentSelection.removeAll(idsInStore);
}
emit(state.copyWith(selectedStaffIds: currentSelection.toList()));
}
// --- 4. AGGIORNAMENTO CAMPI STANDARD ---
void updateTitle(String title) => emit(state.copyWith(title: title));
void updateDescription(String desc) =>
emit(state.copyWith(description: desc));
void updateDueDate(DateTime? date) =>
emit(state.copyWith(dueDate: date, clearDueDate: date == null));
void updateLinkedTicket(String? ticketId) =>
emit(state.copyWith(linkedTicketId: ticketId));
// --- 5. LOGICA DEI REMINDER FINTI ---
void addMockReminder(String type, int minutes) {
final currentReminders = List<TaskReminder>.from(state.reminders);
currentReminders.add(TaskReminder(type: type, minutesBefore: minutes));
emit(state.copyWith(reminders: currentReminders));
}
void removeMockReminder(int index) {
final currentReminders = List<TaskReminder>.from(state.reminders);
currentReminders.removeAt(index);
emit(state.copyWith(reminders: currentReminders));
}
// --- 6. SALVATAGGIO FINALE ---
Future<void> saveTask({required String currentUserId}) async {
if (!state.isFormValid) return;
emit(state.copyWith(status: TaskFormStatus.submitting));
try {
final taskToSave = TaskModel(
id: state.id,
companyId: currentCompanyId,
storeId: state.isGlobal
? null
: currentStoreId, // La vera discriminante Globale/Store!
createdById: state.id == null
? currentUserId
: null, // Lo settiamo solo alla creazione
title: state.title.trim(),
description: state.description.trim().isEmpty
? null
: state.description.trim(),
dueDate: state.dueDate,
status: state.taskStatus,
assignedToIds: state
.selectedStaffIds, // L'array che andrà a popolare la tabella di giunzione
);
if (state.id == null) {
await _taskRepository.createTask(taskToSave);
} else {
await _taskRepository.updateTask(taskToSave);
}
emit(state.copyWith(status: TaskFormStatus.success));
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.failure,
errorMessage: 'Errore durante il salvataggio: $e',
),
);
}
}
}

View File

@@ -0,0 +1,109 @@
part of 'task_form_cubit.dart';
enum TaskFormStatus { initial, loading, submitting, success, failure }
/// Placeholder finto per i futuri reminder (pg_cron)
class TaskReminder extends Equatable {
final String type; // es. 'email', 'push'
final int minutesBefore;
const TaskReminder({required this.type, required this.minutesBefore});
@override
List<Object?> get props => [type, minutesBefore];
}
class TaskFormState extends Equatable {
final TaskFormStatus status;
final String? id; // Null se stiamo creando un nuovo task
final String title;
final String description;
final DateTime? dueDate;
final TaskStatus taskStatus;
// --- SCOPING & ASSIGNMENTS ---
final bool isGlobal;
final List<String> selectedStaffIds;
final List<StaffMemberModel> availableStaff;
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
// --- FUTURI ANCORAGGI ---
final List<TaskReminder> reminders;
final String? linkedTicketId;
final String? linkedEmailId;
final String? errorMessage;
const TaskFormState({
this.status = TaskFormStatus.initial,
this.id,
this.title = '',
this.description = '',
this.dueDate,
this.taskStatus = TaskStatus.open,
this.isGlobal = false,
this.selectedStaffIds = const [],
this.availableStaff = const [],
this.groupedAvailableStaff = const {},
this.reminders = const [],
this.linkedTicketId,
this.linkedEmailId,
this.errorMessage,
});
// MAGIA: Il form è valido solo se c'è un titolo e, opzionalmente, altre regole.
bool get isFormValid => title.trim().isNotEmpty;
TaskFormState copyWith({
TaskFormStatus? status,
String? id,
String? title,
String? description,
DateTime? dueDate,
bool clearDueDate = false, // Trucco Ninja per rimettere a null una data!
TaskStatus? taskStatus,
bool? isGlobal,
List<String>? selectedStaffIds,
List<StaffMemberModel>? availableStaff,
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
List<TaskReminder>? reminders,
String? linkedTicketId,
String? linkedEmailId,
String? errorMessage,
}) {
return TaskFormState(
status: status ?? this.status,
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
taskStatus: taskStatus ?? this.taskStatus,
isGlobal: isGlobal ?? this.isGlobal,
selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds,
availableStaff: availableStaff ?? this.availableStaff,
groupedAvailableStaff:
groupedAvailableStaff ?? this.groupedAvailableStaff,
reminders: reminders ?? this.reminders,
linkedTicketId: linkedTicketId ?? this.linkedTicketId,
linkedEmailId: linkedEmailId ?? this.linkedEmailId,
errorMessage:
errorMessage, // Se copyWith è chiamato senza errore, lo pulisce in automatico
);
}
@override
List<Object?> get props => [
status,
id,
title,
description,
dueDate,
taskStatus,
isGlobal,
selectedStaffIds,
availableStaff,
groupedAvailableStaff,
reminders,
linkedTicketId,
linkedEmailId,
errorMessage,
];
}

View File

@@ -0,0 +1,73 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:get_it/get_it.dart';
part 'task_list_state.dart';
class TaskListCubit extends Cubit<TaskListState> {
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final String currentCompanyId;
final String? currentStoreId;
// Il nostro abbonamento allo stream del repository
StreamSubscription<void>? _taskSubscription;
TaskListCubit({required this.currentCompanyId, this.currentStoreId})
: super(const TaskListState()) {
_initRealtime();
}
void _initRealtime() {
emit(state.copyWith(status: TaskListStatus.loading));
// Primo caricamento
_loadTasksSilently();
// Ci mettiamo in ascolto del campanello del Repository
_taskSubscription = _repository.watchCompanyTasks(currentCompanyId).listen((
_,
) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTasksSilently();
});
}
Future<void> loadTasks() async {
emit(state.copyWith(status: TaskListStatus.loading));
await _loadTasksSilently();
}
Future<void> _loadTasksSilently() async {
try {
final tasks = await _repository.getTasks(
companyId: currentCompanyId,
storeId: currentStoreId,
);
emit(
state.copyWith(
status: TaskListStatus.success,
tasks: tasks,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: TaskListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
@override
Future<void> close() {
// Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto!
_taskSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'task_list_cubit.dart';
enum TaskListStatus { initial, loading, success, failure }
class TaskListState extends Equatable {
final TaskListStatus status;
final List<TaskModel> tasks;
final String? errorMessage;
const TaskListState({
this.status = TaskListStatus.initial,
this.tasks = const [],
this.errorMessage,
});
TaskListState copyWith({
TaskListStatus? status,
List<TaskModel>? tasks,
String? errorMessage,
}) {
return TaskListState(
status: status ?? this.status,
tasks: tasks ?? this.tasks,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, tasks, errorMessage];
}

View File

@@ -0,0 +1,211 @@
import 'dart:async';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// Sostituisci con i percorsi corretti di FLUX
import 'package:flux/features/tasks/models/task_model.dart';
class TaskRepository {
final SupabaseClient _supabase;
TaskRepository({SupabaseClient? supabase})
: _supabase = supabase ?? Supabase.instance.client;
// --- LOGICA REAL-TIME (Il Campanello) ---
Stream<void> watchCompanyTasks(String companyId) {
// Usiamo un broadcast nel caso più bloc volessero ascoltarlo in futuro
final controller = StreamController<void>.broadcast();
final channel = _supabase.channel('public:tasks_company_$companyId');
channel
.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: Tables.tasks,
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'company_id',
value: companyId,
),
callback: (payload) {
if (!controller.isClosed) {
controller.add(
null,
); // Suoniamo il campanello! Nessun dato, solo il "ding"
}
},
)
.subscribe();
// Quando il Cubit smette di ascoltare, puliamo il canale Supabase in automatico
controller.onCancel = () {
channel.unsubscribe();
controller.close();
};
return controller.stream;
}
// --- RECUPERO DEI TASK FILTRATI ---
Future<List<TaskModel>> getTasks({
required String companyId,
String? storeId,
String? staffId,
List<TaskStatus>? statuses,
int? limit,
}) async {
try {
// 1. FASE FILTRI: Usa il join esplicito stile "Notes"
var filterBuilder = _supabase
.from(Tables.tasks)
.select('''
*,
task_assignments:${Tables.taskAssignments} (
${Tables.staffMembers} (*)
)
''')
.eq('company_id', companyId);
if (storeId != null) {
filterBuilder = filterBuilder.or(
'store_id.eq.$storeId,store_id.is.null',
);
}
if (staffId != null) {
// Grazie al trigger, hai l'array pronto per il filtro senza impazzire!
filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]);
}
if (statuses != null && statuses.isNotEmpty) {
final statusValues = statuses.map((s) => s.toValue).toList();
filterBuilder = filterBuilder.inFilter('status', statusValues);
}
// 2. FASE TRASFORMAZIONI
var transformBuilder = filterBuilder
.order('due_date', ascending: true, nullsFirst: false)
.order('created_at', ascending: false, nullsFirst: false);
if (limit != null) {
transformBuilder = transformBuilder.limit(limit);
}
// 3. ESECUZIONE DELLA QUERY
final response = await transformBuilder;
// 4. PARSING DEI DATI
return (response as List).map((json) => TaskModel.fromMap(json)).toList();
} catch (e) {
throw Exception('Errore nel recupero dei task: $e');
}
}
// --- 2. CREAZIONE DEL TASK ---
Future<TaskModel> createTask(TaskModel task) async {
try {
final taskData = task.toMap();
// Rimuoviamo l'array prima di inviare i dati alla tabella principale,
// la "Strada C" impone che la verità assoluta derivi dalla tabella di giunzione!
taskData.remove('assigned_to_ids');
// 1. Inseriamo il record base nella tabella tasks
final response = await _supabase
.from(Tables.tasks)
.insert(taskData)
.select()
.single();
final newTask = TaskModel.fromMap(response);
// 2. Se l'utente ha assegnato il task a qualcuno, popoliamo la giunzione
if (task.assignedToIds.isNotEmpty && newTask.id != null) {
await _syncAssignments(newTask.id!, task.assignedToIds);
// 3. Ricarichiamo il task appena creato per ottenere l'array e il JOIN aggiornati dal trigger!
return await getTaskById(newTask.id!);
}
return newTask;
} catch (e) {
throw Exception('Errore nella creazione del task: $e');
}
}
// --- 3. AGGIORNAMENTO DEL TASK ---
Future<TaskModel> updateTask(TaskModel task) async {
if (task.id == null) {
throw Exception('ID Task mancante. Impossibile aggiornare.');
}
try {
final taskData = task.toMap();
taskData.remove(
'assigned_to_ids',
); // Sempre via l'array dal body principale
// 1. Aggiorniamo i dati base del task (titolo, stato, scadenza, ecc.)
await _supabase.from(Tables.tasks).update(taskData).eq('id', task.id!);
// 2. Sincronizziamo in modo distruttivo gli assegnatari nella tabella di giunzione
await _syncAssignments(task.id!, task.assignedToIds);
// 3. Ritorniamo il modello fresco di DB
return await getTaskById(task.id!);
} catch (e) {
throw Exception('Errore nell\'aggiornamento del task: $e');
}
}
// --- 4. ELIMINAZIONE DEL TASK ---
Future<void> deleteTask(String taskId) async {
try {
// Eliminando il task, se la Foreign Key su Supabase ha "ON DELETE CASCADE",
// le righe nella tabella di giunzione si distruggeranno da sole in automatico!
await _supabase.from(Tables.tasks).delete().eq('id', taskId);
} catch (e) {
throw Exception('Errore nell\'eliminazione del task: $e');
}
}
// --- HELPER: RECUPERO SINGOLO TASK ---
Future<TaskModel> getTaskById(String taskId) async {
try {
final response = await _supabase
.from(Tables.tasks)
.select('''
*,
task_assignments:${Tables.taskAssignments} (
${Tables.staffMembers} (*)
)
''')
.eq('id', taskId)
.single();
return TaskModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel recupero del singolo task: $e');
}
}
// --- HELPER: SINCRONIZZAZIONE DELLA TABELLA DI GIUNZIONE ---
Future<void> _syncAssignments(String taskId, List<String> staffIds) async {
// TECNICA "WIPE & REPLACE":
// Invece di capire chi è stato aggiunto e chi tolto, eliminiamo tutti i record
// di questo task e li reinseriamo. È fulmineo ed esclude a priori bug di disallineamento.
// 1. Facciamo piazza pulita
await _supabase.from(Tables.taskAssignments).delete().eq('task_id', taskId);
// 2. Inseriamo le nuove assegnazioni (se ce ne sono)
if (staffIds.isNotEmpty) {
final List<Map<String, dynamic>> assignments = staffIds
.map((staffId) => {'task_id': taskId, 'staff_id': staffId})
.toList();
await _supabase.from(Tables.taskAssignments).insert(assignments);
}
}
}

View File

@@ -0,0 +1,152 @@
import 'package:equatable/equatable.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
class TaskModel extends Equatable {
final String? id;
final String? companyId;
final String title;
final String? description;
final List<String> assignedToIds;
final List<StaffMemberModel> assignedToStaff; // I dati completi dal JOIN
final String? createdById;
final DateTime? dueDate;
final TaskStatus status;
final DateTime? createdAt;
final String? storeId;
const TaskModel({
this.id,
this.companyId,
required this.title,
this.description,
this.assignedToIds = const [],
this.assignedToStaff = const [],
this.createdById,
this.dueDate,
this.status = TaskStatus.open,
this.createdAt,
this.storeId,
});
// --- FACTORY: MODELLO VUOTO (Per le creazioni) ---
factory TaskModel.empty({String? companyId, String? createdById}) {
return TaskModel(
companyId: companyId,
title: '',
description: '',
assignedToIds: const [],
assignedToStaff: const [],
createdById: createdById,
status: TaskStatus.open,
createdAt: DateTime.now(),
);
}
// --- EQUATABLE: PROPRIETÀ DA COMPARARE ---
@override
List<Object?> get props => [
id,
companyId,
title,
description,
assignedToIds,
assignedToStaff,
createdById,
dueDate,
status,
createdAt,
storeId,
];
// --- COPY WITH ---
TaskModel copyWith({
String? id,
String? companyId,
String? title,
String? description,
List<String>? assignedToIds,
List<StaffMemberModel>? assignedToStaff,
String? createdById,
DateTime? dueDate,
bool clearDueDate = false, // Flag ninja per resettare la scadenza
TaskStatus? status,
DateTime? createdAt,
String? storeId,
bool clearStoreId = false,
}) {
return TaskModel(
id: id ?? this.id,
companyId: companyId ?? this.companyId,
title: title ?? this.title,
description: description ?? this.description,
assignedToIds: assignedToIds ?? this.assignedToIds,
assignedToStaff: assignedToStaff ?? this.assignedToStaff,
createdById: createdById ?? this.createdById,
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
storeId: clearStoreId ? null : (storeId ?? this.storeId),
);
}
// --- SERIALIZZAZIONE DA SUPABASE ---
factory TaskModel.fromMap(Map<String, dynamic> map) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedToIds = map['assigned_to_ids'] != null
? List<String>.from(map['assigned_to_ids'])
: [];
// 2. Mappiamo il JOIN dello staff, se presente
List<StaffMemberModel> staffList = [];
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
if (map['task_assignments'] != null) {
staffList = (map['task_assignments'] as List)
.map((a) => a['staff_members'])
.where((s) => s != null)
.map((s) => StaffMemberModel.fromMap(s))
.toList();
}
// Gestione del JSON piatto (se mai lo userai in altre chiamate RPC o viste)
else if (map['assigned_to_staff'] != null) {
staffList = (map['assigned_to_staff'] as List)
.map((s) => StaffMemberModel.fromMap(s))
.toList();
}
return TaskModel(
id: map['id'] as String?,
companyId: map['company_id'] as String?,
title: map['title'] as String? ?? '',
description: map['description'] as String?,
assignedToIds: parsedAssignedToIds,
assignedToStaff: staffList,
createdById: map['created_by_id'] as String?,
dueDate: map['due_date'] != null
? DateTime.parse(map['due_date'] as String).toLocal()
: null,
status: TaskStatusExtension.fromString(map['status'] as String?),
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'] as String).toLocal()
: null,
storeId: map['store_id'] as String?,
);
}
// --- SERIALIZZAZIONE VERSO SUPABASE ---
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
if (companyId != null) 'company_id': companyId,
'title': title,
if (description != null) 'description': description,
// Passiamo l'array vuoto se non ci sono assegnazioni
'assigned_to_ids': assignedToIds.isEmpty ? null : assignedToIds,
if (createdById != null) 'created_by_id': createdById,
'due_date': dueDate?.toUtc().toIso8601String(),
'status': status.toValue,
'store_id': storeId,
};
}
}

View File

@@ -0,0 +1,40 @@
// Enum per lo stato del task
enum TaskStatus { open, inProgress, completed }
extension TaskStatusExtension on TaskStatus {
String get name {
switch (this) {
case TaskStatus.open:
return 'Da Iniziare';
case TaskStatus.inProgress:
return 'In Lavorazione';
case TaskStatus.completed:
return 'Completato';
}
}
// Comodo per mappare da Supabase
static TaskStatus fromString(String? status) {
switch (status) {
case 'in_progress':
return TaskStatus.inProgress;
case 'completed':
return TaskStatus.completed;
case 'open':
default:
return TaskStatus.open;
}
}
// Comodo per salvare su Supabase
String get toValue {
switch (this) {
case TaskStatus.open:
return 'open';
case TaskStatus.inProgress:
return 'in_progress';
case TaskStatus.completed:
return 'completed';
}
}
}

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
import 'package:go_router/go_router.dart';
class TaskFormScreen extends StatefulWidget {
const TaskFormScreen({super.key});
@override
State<TaskFormScreen> createState() => _TaskFormScreenState();
}
class _TaskFormScreenState extends State<TaskFormScreen> {
late final TextEditingController _titleController;
late final TextEditingController _descController;
@override
void initState() {
super.initState();
// Leggiamo lo stato iniziale dal Cubit (che ha già i dati del task esistente)
final initialState = context.read<TaskFormCubit>().state;
_titleController = TextEditingController(text: initialState.title);
_descController = TextEditingController(text: initialState.description);
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<TaskFormCubit, TaskFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
// GESTIONE DEEP LINK: Se eravamo in caricamento e ora siamo pronti, popoliamo i controller!
if (state.status == TaskFormStatus.initial) {
if (_titleController.text != state.title) {
_titleController.text = state.title;
}
if (_descController.text != state.description) {
_descController.text = state.description;
}
}
if (state.status == TaskFormStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Task salvato con successo! 🎉')),
);
context.pop();
} else if (state.status == TaskFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
builder: (context, state) {
final cubit = context.read<TaskFormCubit>();
final isEditing = state.id != null;
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Modifica Task' : 'Nuovo Task'),
actions: [
if (state.status == TaskFormStatus.submitting)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton.icon(
onPressed: state.isFormValid
? () => cubit.saveTask(currentUserId: 'TODO_USER_ID')
: null,
icon: const Icon(Icons.save),
label: const Text('Salva'),
style: TextButton.styleFrom(
foregroundColor: Colors.orange,
disabledForegroundColor: Colors.grey,
),
),
],
),
body: state.status == TaskFormStatus.loading
// Se sta scaricando i dati dal Deep Link, mostriamo un bel loader centrato
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 800;
if (isWideScreen) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: _buildFormFields(context, state, cubit),
),
),
VerticalDivider(
color: Theme.of(context).dividerColor,
),
Expanded(
flex: 4,
child: _buildStaffSelectorInline(
context,
state,
cubit,
),
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFormFields(context, state, cubit),
const SizedBox(height: 30),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () =>
_showStaffBottomSheet(context, cubit),
icon: const Icon(Icons.group_add),
label: Text(
state.selectedStaffIds.isEmpty
? 'Assegna Staff'
: 'Assegnato a ${state.selectedStaffIds.length} persone',
),
),
],
),
);
},
),
);
},
);
}
// --- I CAMPI DEL FORM (Aggiornati con i Controller) ---
Widget _buildFormFields(
BuildContext context,
TaskFormState state,
TaskFormCubit cubit,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
),
),
child: SwitchListTile(
title: const Text(
'Task Globale Aziendale',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Visibile a tutta l\'azienda, non legato a un negozio specifico.',
),
value: state.isGlobal,
activeThumbColor: Colors.orange,
onChanged: (val) => cubit.toggleGlobalScope(val),
),
),
const SizedBox(height: 24),
// Addio initialValue, benvenuto controller!
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Titolo del Task*',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
onChanged: cubit.updateTitle,
),
const SizedBox(height: 16),
TextFormField(
controller: _descController,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Descrizione (opzionale)',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
onChanged: cubit.updateDescription,
),
const SizedBox(height: 24),
// --- SCADENZA ---
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).dividerColor),
),
leading: const Icon(Icons.calendar_today, color: Colors.orange),
title: Text(
state.dueDate != null
? 'Scadenza: ${state.dueDate!.day}/${state.dueDate!.month}/${state.dueDate!.year}'
: 'Nessuna scadenza impostata',
),
trailing: state.dueDate != null
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => cubit.updateDueDate(null),
)
: null,
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: state.dueDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime(2100),
);
if (date != null) cubit.updateDueDate(date);
},
),
],
);
}
// =========================================================================
// 2. SELEZIONE STAFF INLINE (PER DESKTOP/WIDE)
// =========================================================================
Widget _buildStaffSelectorInline(
BuildContext context,
TaskFormState state,
TaskFormCubit cubit,
) {
return Container(
color: Theme.of(context).cardColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.all(24.0),
child: Text(
'Assegnazione Staff',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: _buildGroupedStaffList(context, state, cubit),
),
),
],
),
);
}
// =========================================================================
// 3. BOTTOM SHEET SELEZIONE STAFF (PER MOBILE)
// =========================================================================
void _showStaffBottomSheet(BuildContext context, TaskFormCubit cubit) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (bottomSheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7, // Occupa il 70% dello schermo in altezza
minChildSize: 0.5,
maxChildSize: 0.9,
builder: (_, controller) {
return BlocBuilder<TaskFormCubit, TaskFormState>(
bloc:
cubit, // Passiamo il cubit esistente per mantenere lo stato!
builder: (context, state) {
return Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Assegna Staff',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 1),
Expanded(
child: ListView(
controller: controller,
padding: const EdgeInsets.only(bottom: 24),
children: _buildGroupedStaffList(context, state, cubit),
),
),
],
);
},
);
},
);
},
);
}
// =========================================================================
// 4. GENERATORE DELLA LISTA RAGGRUPPATA (RIUTILIZZABILE)
// =========================================================================
List<Widget> _buildGroupedStaffList(
BuildContext context,
TaskFormState state,
TaskFormCubit cubit,
) {
final widgets = <Widget>[];
if (state.groupedAvailableStaff.isEmpty) {
return [
const Padding(
padding: EdgeInsets.all(32.0),
child: Center(
child: Text(
'Nessun membro dello staff trovato.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
),
];
}
// Iteriamo sulla mappa { "Nome Negozio" : [Lista Dipendenti] }
for (final entry in state.groupedAvailableStaff.entries) {
final storeName = entry.key;
final staffList = entry.value;
// Verifichiamo se TUTTI i membri di questo negozio sono selezionati
final allSelectedInStore = staffList.every(
(staff) => state.selectedStaffIds.contains(staff.id),
);
widgets.add(
Padding(
padding: const EdgeInsets.only(
top: 24.0,
bottom: 8.0,
left: 16,
right: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
storeName.toUpperCase(),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
// IL MAGICO BOTTONE "SELEZIONA TUTTI" DEL NEGOZIO
TextButton.icon(
onPressed: () =>
cubit.toggleStoreSelection(storeName, !allSelectedInStore),
icon: Icon(
allSelectedInStore ? Icons.deselect : Icons.select_all,
size: 18,
),
label: Text(
allSelectedInStore ? 'Deseleziona' : 'Seleziona Tutti',
),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: allSelectedInStore
? Colors.grey
: Colors.orange,
),
),
],
),
),
);
// Renderizziamo i dipendenti di questo negozio usando dei Wrap con FilterChip
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: staffList.map((staff) {
final isSelected = state.selectedStaffIds.contains(staff.id);
return FilterChip(
label: Text(staff.name),
selected: isSelected,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onSelected: (_) => cubit.toggleStaffSelection(staff.id!),
);
}).toList(),
),
),
);
}
return widgets;
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
import 'package:go_router/go_router.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/features/tasks/models/task_status.dart'; // Adegua al tuo path
import 'package:flux/features/tasks/models/task_model.dart';
class TaskListScreen extends StatelessWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context) {
// Usiamo 3 tab per gli stati principali
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Gestione Task'),
bottom: const TabBar(
indicatorColor: Colors.orange,
labelColor: Colors.orange,
tabs: [
Tab(text: 'Da Fare'),
Tab(text: 'In Corso'),
Tab(text: 'Completati'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<TaskListCubit>().loadTasks(),
),
],
),
body: BlocBuilder<TaskListCubit, TaskListState>(
builder: (context, state) {
if (state.status == TaskListStatus.loading ||
state.status == TaskListStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == TaskListStatus.failure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(state.errorMessage ?? 'Errore sconosciuto'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
context.read<TaskListCubit>().loadTasks(),
child: const Text('Riprova'),
),
],
),
);
}
// Filtriamo le 3 liste in memoria per ogni Tab
final todoTasks = state.tasks
.where((t) => t.status == TaskStatus.open)
.toList();
final inProgressTasks = state.tasks
.where((t) => t.status == TaskStatus.inProgress)
.toList();
final doneTasks = state.tasks
.where((t) => t.status == TaskStatus.completed)
.toList(); // Adegua in base ai tuoi enum
return TabBarView(
children: [
_buildTaskList(context, todoTasks, 'Nessun task da fare. 🎉'),
_buildTaskList(
context,
inProgressTasks,
'Nessun task in lavorazione.',
),
_buildTaskList(context, doneTasks, 'Nessun task completato.'),
],
);
},
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: Colors.orange,
onPressed: () =>
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'}),
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'Nuovo Task',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
);
}
// --- WIDGET LISTA ---
Widget _buildTaskList(
BuildContext context,
List<TaskModel> tasks,
String emptyMessage,
) {
if (tasks.isEmpty) {
return Center(
child: Text(
emptyMessage,
style: TextStyle(
color: Theme.of(context).textTheme.bodySmall?.color,
fontStyle: FontStyle.italic,
),
),
);
}
return RefreshIndicator(
onRefresh: () => context.read<TaskListCubit>().loadTasks(),
child: ListView.separated(
padding: const EdgeInsets.only(
top: 16,
bottom: 80,
left: 16,
right: 16,
), // Padding bottom per il FAB
itemCount: tasks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final task = tasks[index];
final isOverdue =
task.dueDate != null && task.dueDate!.isBefore(DateTime.now());
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.pushNamed(
Routes.taskForm,
pathParameters: {'id': task.id!},
extra: task,
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Riga 1: Badge Globale/Store + Data Scadenza
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: task.storeId == null
? Colors.purple.withValues(alpha: 0.1)
: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
task.storeId == null ? 'GLOBALE' : 'STORE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: task.storeId == null
? Colors.purple
: Colors.blue,
),
),
),
if (task.dueDate != null)
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: isOverdue ? Colors.red : Colors.grey,
),
const SizedBox(width: 4),
Text(
'${task.dueDate!.day}/${task.dueDate!.month}/${task.dueDate!.year}',
style: TextStyle(
fontSize: 12,
fontWeight: isOverdue
? FontWeight.bold
: FontWeight.normal,
color: isOverdue ? Colors.red : Colors.grey,
),
),
],
),
],
),
const SizedBox(height: 12),
// Riga 2: Titolo
Text(
task.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Riga 3 (Opzionale): Descrizione breve
if (task.description != null &&
task.description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
task.description!,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 16),
// Riga 4: Assegnatari
Row(
children: [
const Icon(
Icons.people_outline,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Expanded(
child: Text(
(task.assignedToStaff.isEmpty)
? 'Nessun assegnatario'
: task.assignedToStaff
.map((s) => s.name)
.join(', '),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -138,6 +138,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
String? assignedToId,
String? assignedToName,
WarrantyType? warrantyType,
DateTime? estimatedDeliveryAt,
bool clearEstimatedDelivery = false,
}) {
emit(
state.copyWith(
@@ -162,6 +164,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
assignedToId: assignedToId ?? state.ticket.assignedToId,
assignedToName: assignedToName ?? state.ticket.assignedToName,
warrantyType: warrantyType ?? state.ticket.warrantyType,
estimatedDeliveryAt: estimatedDeliveryAt,
clearEstimatedDelivery: clearEstimatedDelivery,
),
),
);

View File

@@ -1,13 +1,16 @@
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: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_list_state.dart';
class TicketListCubit extends Cubit<TicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final TrackingRepository _trackingRepository = GetIt.I
.get<TrackingRepository>();
static const int _limit = 20; // Paginazione a blocchi di 20
TicketListCubit() : super(const TicketListState()) {
@@ -95,4 +98,64 @@ class TicketListCubit extends Cubit<TicketListState> {
void selectAll(List<TicketModel> tickets) {
emit(state.copyWith(selectedTickets: tickets.toSet()));
}
Future<void> closeTicketsBulk({
required List<String> ticketIds,
Map<String, bool>? loanReturns,
}) async {
// 1. Escludiamo i ticket per cui NON è stato restituito il muletto
if (loanReturns != null) {
for (final map in loanReturns.entries) {
if (!map.value) {
ticketIds.remove(map.key);
}
}
}
// Se non c'è più nulla da chiudere (es. ha rifiutato tutto), usciamo
if (ticketIds.isEmpty) {
clearSelection();
return;
}
// 2. Prepariamo i ticket per il DB
final List<TicketModel> ticketsToUpdate = [];
for (final ticketId in ticketIds) {
final ticket = state.tickets
.firstWhere((ticket) => ticket.id == ticketId)
.copyWith(ticketStatus: TicketStatus.closed);
ticketsToUpdate.add(ticket);
}
// 3. Salviamo su DB (in background)
for (final ticket in ticketsToUpdate) {
await _repository.updateTicket(ticket);
await _trackingRepository.logQuickEvent(
companyId: ticket.companyId,
message: 'Ticket chiuso - Riconsegnato',
type: TrackingType.statusChange,
parentId: ticket.id!,
parentType: TrackingParentType.ticket,
);
}
// 4. LA MAGIA: AGGIORNAMENTO LOCALE ISTANTANEO
final updatedTickets = state.tickets.map((t) {
if (ticketIds.contains(t.id)) {
return t.copyWith(ticketStatus: TicketStatus.closed);
}
return t;
}).toList();
// 5. Emettiamo il nuovo stato aggiornato e puliamo la selezione in un colpo solo
emit(
state.copyWith(
tickets: updatedTickets,
selectedTickets: {}, // Equivalente di clearSelection()
),
);
// Opzionale: Se vuoi comunque riallinearti al server in modo silenzioso dopo l'animazione
// loadTickets(refresh: true);
}
}

View File

@@ -259,6 +259,18 @@ class TicketRepository {
}
}
/// Chiude i ticket in bulk
Future<void> closeTickets(List<String> ticketIds) async {
try {
await _supabase
.from(_tableName)
.update({'ticket_status': TicketStatus.closed.value})
.inFilter('id', ticketIds);
} catch (e) {
throw Exception('Errore nella chiusura dei ticket: $e');
}
}
/// Elimina (o annulla) un ticket
Future<void> deleteTicket(String ticketId) async {
try {

View File

@@ -203,6 +203,7 @@ class TicketModel extends Equatable {
TicketType? ticketType,
TicketStatus? ticketStatus,
DateTime? estimatedDeliveryAt,
bool clearEstimatedDelivery = false,
TicketResult? ticketResult,
String? resolutionNotes,
String? shippingDocumentId,
@@ -242,7 +243,9 @@ class TicketModel extends Equatable {
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
ticketType: ticketType ?? this.ticketType,
ticketStatus: ticketStatus ?? this.ticketStatus,
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
estimatedDeliveryAt: clearEstimatedDelivery
? null
: (estimatedDeliveryAt ?? this.estimatedDeliveryAt),
ticketResult: ticketResult ?? this.ticketResult,
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,

View File

@@ -141,6 +141,55 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
}
}
// Formatta in "GG/MM/AAAA HH:MM"
String _formatDateTime(DateTime dt) {
return "${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}";
}
// Lancia i popup di Data e poi di Ora
Future<void> _selectDeliveryDate(
BuildContext context,
TicketModel ticket,
) async {
final initialDate = ticket.estimatedDeliveryAt ?? DateTime.now();
// 1. Chiediamo la Data
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(
2020,
), // Oppure DateTime.now() se non vuoi date passate
lastDate: DateTime(2100),
);
if (pickedDate == null) return; // L'utente ha annullato
// 2. Chiediamo l'Ora
if (!context.mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(initialDate),
);
if (pickedTime == null) return; // L'utente ha annullato
// 3. Fondiamo Data e Ora in un unico DateTime
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
// 4. Aggiorniamo il Cubit
if (!context.mounted) return;
context.read<TicketFormCubit>().updateFields(
estimatedDeliveryAt: finalDateTime,
);
}
Future<String?> _generateIdForQr() async {
// 1. Validiamo i campi obbligatori (es. il cliente)
if (!_formKey.currentState!.validate()) return null;
@@ -784,6 +833,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
children: [
Expanded(
child: DropdownButtonFormField<TicketType>(
isExpanded: true,
initialValue: ticket.ticketType,
decoration: const InputDecoration(
labelText: 'Tipo Lavorazione',
@@ -804,6 +854,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<TicketStatus>(
isExpanded: true,
initialValue: ticket.ticketStatus,
decoration: const InputDecoration(labelText: 'Stato Attuale'),
items: TicketStatus.values
@@ -815,6 +866,37 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
),
],
),
const SizedBox(height: 16),
TextFormField(
readOnly: true, // MAGIA: Impedisce l'apertura della tastiera
// Creiamo un controller "al volo" solo per mostrargli la stringa
controller: TextEditingController(
text: ticket.estimatedDeliveryAt != null
? _formatDateTime(ticket.estimatedDeliveryAt!)
: '',
),
decoration: InputDecoration(
labelText: 'Riconsegna prevista (Data e Ora)',
prefixIcon: const Icon(Icons.event_available),
// Bottone con la X per rimuovere la data se il cliente ti dice "fai con calma"
suffixIcon: ticket.estimatedDeliveryAt != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
// NOTA: Dovrai assicurarti che il tuo Cubit gestisca il reset.
// O passi un flag come clearEstimatedDelivery: true,
// o gestisci il null se il tuo updateFields lo permette.
context.read<TicketFormCubit>().updateFields(
clearEstimatedDelivery:
true, // Esempio di flag da aggiungere nel Cubit
);
},
)
: null,
),
// Quando tappi il campo di testo, partono i calendari
onTap: () => _selectDeliveryDate(context, ticket),
),
if (ticket.ticketType == TicketType.repair) ...[
const SizedBox(height: 16),
DropdownButtonFormField<WarrantyType>(
@@ -1001,11 +1083,15 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
child: Icon(icon, color: themeColor),
),
const SizedBox(width: 12),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
),
],

View File

@@ -46,6 +46,11 @@ class _TicketListScreenState extends State<TicketListScreen> {
appBar: AppBar(
title: const Text('Assistenza & Riparazioni'),
actions: [
IconButton(
onPressed: () =>
context.read<TicketListCubit>().loadTickets(refresh: true),
icon: const Icon(Icons.refresh),
),
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
IconButton(
icon: const Icon(Icons.filter_list),

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
class LoanPhoneReturnDialog extends StatefulWidget {
final List<TicketModel> ticketsWithLoans;
const LoanPhoneReturnDialog({super.key, required this.ticketsWithLoans});
@override
State<LoanPhoneReturnDialog> createState() => _LoanPhoneReturnDialogState();
}
class _LoanPhoneReturnDialogState extends State<LoanPhoneReturnDialog> {
// Mappa per tenere traccia delle scelte: { ticketId: true/false }
final Map<String, bool> _returnStatuses = {};
@override
void initState() {
super.initState();
// Inizializziamo tutto a "true" (di default presumiamo che lo stia restituendo)
for (var ticket in widget.ticketsWithLoans) {
_returnStatuses[ticket.id!] = true;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Telefoni di cortesia'),
],
),
content: SizedBox(
width: double.maxFinite,
// Usiamo ListView.builder in caso ce ne siano tanti
child: ListView.separated(
shrinkWrap: true,
itemCount: widget.ticketsWithLoans.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final ticket = widget.ticketsWithLoans[index];
final customerName = ticket.customer?.name ?? 'Cliente';
return SwitchListTile(
title: Text(
'$customerName ha un telefono di cortesia in prestito.',
),
subtitle: const Text('Confermi la riconsegna?'),
value: _returnStatuses[ticket.id!] ?? true,
activeThumbColor: Theme.of(context).colorScheme.primary,
onChanged: (bool value) {
setState(() {
_returnStatuses[ticket.id!] = value;
});
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null), // Annulla tutto
child: const Text('Annulla'),
),
FilledButton(
onPressed: () =>
Navigator.of(context).pop(_returnStatuses), // Passa la mappa
child: const Text('Conferma'),
),
],
);
}
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/blocs/ticket_shipping_cubit.dart';
import 'package:flux/features/tickets/ui/widgets/loan_phone_return_dialog.dart';
import 'package:flux/features/tickets/ui/widgets/ticket_list_card.dart';
import 'package:flux/features/tickets/ui/widgets/ticket_shipping_modal.dart';
@@ -17,6 +17,67 @@ class TicketList extends StatelessWidget {
required this.state,
});
void _showShippingModal(BuildContext context) async {
// 1. Apriamo la modale e ASPETTIAMO il risultato (tipizzandolo come Record)
final bool? result = await showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
builder: (context) {
return BlocProvider(
create: (context) =>
TicketShippingCubit(tickets: state.selectedTickets.toList())
..loadRepairCenters(),
child: TicketShippingModal(
ticketIds: state.selectedTickets.map((t) => t.id!).toList(),
),
);
},
);
// 2. Se l'utente ha chiuso trascinando giù, result è null.
// Se ha salvato con successo, result contiene il nostro Record!
if (result != null && context.mounted) {
// 5. Pulizia finale: Deselezioniamo tutti i ticket e ricarichiamo la lista
context.read<TicketListCubit>().clearSelection();
// (Se necessario, chiama il metodo per ricaricare la lista dei ticket dal DB)
context.read<TicketListCubit>().loadTickets(refresh: true);
}
}
void _setStatusClosed(BuildContext context) async {
// 1. Filtriamo solo i ticket che hanno un telefono in prestito
final ticketsWithLoans = state.selectedTickets
.where((t) => t.hasCourtesyDevice == true)
.toList();
// Prepariamo la variabile per contenere i telefoni restituiti (se ce ne sono)
Map<String, bool>? loanReturns;
// 2. Se ci sono telefoni in prestito, mostriamo il popup
if (ticketsWithLoans.isNotEmpty) {
loanReturns = await showDialog<Map<String, bool>>(
context: context,
builder: (context) =>
LoanPhoneReturnDialog(ticketsWithLoans: ticketsWithLoans),
);
// Se l'utente ha premuto fuori o ha fatto "Annulla", blocchiamo l'operazione bulk
if (loanReturns == null) return;
}
// 3. Se siamo qui, o non c'erano muletti, o l'utente ha confermato il popup.
// Lanciamo l'azione sul Cubit! (Dovrai creare/adattare questo metodo nel tuo Cubit)
if (context.mounted) {
final ticketIds = state.selectedTickets.map((t) => t.id!).toList();
// Passiamo gli ID dei ticket da chiudere e la mappa delle restituzioni
context.read<TicketListCubit>().closeTicketsBulk(
ticketIds: ticketIds,
loanReturns: loanReturns, // Può essere null se non c'erano muletti
);
}
}
@override
Widget build(BuildContext context) {
return Stack(
@@ -56,75 +117,83 @@ class TicketList extends StatelessWidget {
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
bottom: state.selectedTickets.isNotEmpty
? 90
: -100, // Nasconde o mostra
left: 16,
right: 16,
child: Card(
elevation: 8,
color: Theme.of(context).colorScheme.inverseSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
context.read<TicketListCubit>().clearSelection(),
bottom: state.selectedTickets.isNotEmpty ? 90 : -100,
// Mettiamo left e right a 0 per far occupare tutta la larghezza invisibile
left: 0,
right: 0,
child: Align(
alignment: Alignment.bottomCenter,
// 1. IL LIMITE MASSIMO: Su desktop non supererà mai i 600px, su mobile si restringe da solo
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Card(
elevation: 8,
color: Theme.of(context).colorScheme.inverseSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
16,
), // Qui possiamo giocare coi bordi
),
Text(
'${state.selectedTickets.length} selezionati',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
// 2. LA ROW PRINCIPALE: Spinge tutto ai due estremi del nostro "dock"
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// BLOCCO SINISTRO: Chiusura e Contatore
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close),
color: Theme.of(
context,
).colorScheme.onInverseSurface,
onPressed: () => context
.read<TicketListCubit>()
.clearSelection(),
),
const SizedBox(width: 8),
Text(
'${state.selectedTickets.length} selezionati',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onInverseSurface,
),
),
],
),
// BLOCCO DESTRO: Wrap confinato solo ai bottoni
Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.end,
children: [
IconButton.filled(
tooltip: 'Riconsegna',
onPressed: () => _setStatusClosed(context),
icon: const Icon(Icons.approval),
),
IconButton.filled(
tooltip: 'Spedisci',
onPressed: () => _showShippingModal(context),
icon: const Icon(Icons.local_shipping),
),
],
),
],
),
),
const Spacer(),
// IL NOSTRO FAMOSO BOTTONE SPEDISCI
// IL BOTTONE SPEDISCI NELLA BARRA IN BASSO
FilledButton.icon(
onPressed: () async {
// 1. Apriamo la modale e ASPETTIAMO il risultato (tipizzandolo come Record)
final bool? result = await showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
builder: (context) {
return BlocProvider(
create: (context) => TicketShippingCubit(
tickets: state.selectedTickets.toList(),
)..loadRepairCenters(),
child: TicketShippingModal(
ticketIds: state.selectedTickets
.map((t) => t.id!)
.toList(),
),
);
},
);
// 2. Se l'utente ha chiuso trascinando giù, result è null.
// Se ha salvato con successo, result contiene il nostro Record!
if (result != null && context.mounted) {
// 5. Pulizia finale: Deselezioniamo tutti i ticket e ricarichiamo la lista
context.read<TicketListCubit>().clearSelection();
// (Se necessario, chiama il metodo per ricaricare la lista dei ticket dal DB)
context.read<TicketListCubit>().loadTickets(
refresh: true,
);
}
},
icon: const Icon(Icons.local_shipping),
label: const Text('Spedisci'),
),
],
),
),
),
),

View File

@@ -10,6 +10,7 @@ import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tickets/data/tickets_shipping_repository.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
@@ -136,6 +137,7 @@ Future<void> setupLocator() async {
() => TicketsShippingRepository(),
);
getIt.registerLazySingleton<NotesRepository>(() => NotesRepository());
getIt.registerLazySingleton<TaskRepository>(() => TaskRepository());
}
class FluxApp extends StatefulWidget {
@@ -306,12 +308,14 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
size: 32,
),
const SizedBox(width: 12),
Text(
kIsWeb
? "Aggiornamento"
: "Aggiornamento Obbligatorio",
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
Flexible(
child: Text(
kIsWeb
? "Aggiornamento"
: "Aggiornamento Obbligatorio",
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
@@ -351,12 +355,10 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
onPressed: () async {
if (_updateUrl != null) {
final url = Uri.parse(_updateUrl!);
if (await canLaunchUrl(url)) {
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
}
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
}
},
),

View File

@@ -1,7 +1,7 @@
name: flux
description: "Gestione attività negozio di telefonia"
publish_to: 'none'
version: 1.0.10+10
version: 1.0.18+18
environment:
sdk: ^3.11.3

View File

@@ -9,7 +9,7 @@ DefaultDirName={autopf}\Flux
; Cartella di output dell'installer e nome del file generato
OutputDir=.\build\windows\installer
OutputBaseFilename=FLUX_Setup_v{#MyAppVersion}
OutputBaseFilename=FluxInstaller
; Compressione e impostazioni grafiche
Compression=lzma2/max

View File

@@ -1,6 +1,7 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(flux LANGUAGES CXX)
add_compile_definitions(_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.