Compare commits
41 Commits
8b8dd0a427
...
feat-tasks
| Author | SHA1 | Date | |
|---|---|---|---|
| 83988597d5 | |||
| b298509178 | |||
| b6e5f9acbe | |||
| f6ecb33729 | |||
| 9d796d6e41 | |||
| 45455a16c4 | |||
| 2afe97c6db | |||
| 4101b736e6 | |||
| b67354610d | |||
| b19c91a7dd | |||
| 9b5d19b926 | |||
| aad9a991c2 | |||
| 7f0d18eed1 | |||
| 879c848d77 | |||
| 123c006a1e | |||
| 415811f592 | |||
| 31066a4d8f | |||
| b700c2de8d | |||
| fda5b8fe2e | |||
| b7a525056a | |||
| 7a11e829b3 | |||
| 361b61a694 | |||
| 0cb060c89c | |||
| 4b9cbf65f9 | |||
| 813fc9dd38 | |||
| f574d6197b | |||
| 2fac3117a4 | |||
| 7b072a219d | |||
| 23d3356e6b | |||
| 5b2702daed | |||
| b9c3eb7091 | |||
| 6fbc5d947c | |||
| f520a02226 | |||
| 3a43b2672a | |||
| 61959a5a2e | |||
| 5f16ee2b38 | |||
| a8ebb1dada | |||
| 862719b8b0 | |||
| d1ee6d8a10 | |||
| c3268012a5 | |||
| da24b6a5ed |
99
.gitea/workflows/release.yaml
Normal file
99
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Build and Release FLUX (Multi-Platform)
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 1: WINDOWS (Gira sul PC del collega appena si libera)
|
||||
# -----------------------------------------------------------------
|
||||
build-windows:
|
||||
runs-on: windows-native
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
Set-Content -Path ".env" -Value "${{ secrets.ENV_FILE_CONTENT }}"
|
||||
|
||||
- name: Build Flutter Windows
|
||||
run: flutter build windows --release
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: |
|
||||
$TagVersion = "${{ github.ref_name }}".Substring(1)
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
|
||||
|
||||
# Nel dubbio usiamo l'action per caricare l'asset
|
||||
- name: Upload Windows Asset
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
files: "build/windows/installer/FluxInstaller.exe"
|
||||
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||
|
||||
- name: Pulisci Workspace Windows
|
||||
if: always()
|
||||
run: Remove-Item -Recurse -Force ./*
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 2: ANDROID APK (Gira sul tuo MacBook)
|
||||
# -----------------------------------------------------------------
|
||||
build-android:
|
||||
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Logica Bash per Mac: usiamo le virgolette singole forti per evitare escape strani
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
cat << 'EOF' > .env
|
||||
${{ secrets.ENV_FILE_CONTENT }}
|
||||
EOF
|
||||
|
||||
- name: Build Flutter APK
|
||||
run: flutter build apk --release
|
||||
|
||||
# Carichiamo l'APK universale o quelli splittati nelle release di Gitea
|
||||
- name: Upload Android Asset
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
files: "build/app/outputs/flutter-apk/app-release.apk"
|
||||
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||
|
||||
- name: Aggiorna Link Android su Supabase
|
||||
run: |
|
||||
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
|
||||
|
||||
- name: Aggiorna Link Windows su Supabase
|
||||
run: |
|
||||
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
|
||||
# -----------------------------------------------------------------
|
||||
build-web:
|
||||
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
cat << 'EOF' > .env
|
||||
${{ secrets.ENV_FILE_CONTENT }}
|
||||
EOF
|
||||
|
||||
- name: Build Flutter Web
|
||||
run: flutter build web --release
|
||||
|
||||
# Sfruttiamo npx (incluso in Node.js) per lanciare wrangler al volo senza installarlo globalmente
|
||||
# Sto assumendo che usi Cloudflare Pages che è perfetto per Flutter Web statico
|
||||
- name: Deploy su Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler pages deploy build/web --project-name="flux" --branch="main"
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
: 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,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_special_outlined),
|
||||
selectedIcon: Icon(Icons.folder_special),
|
||||
label: context.l10n.commonMasterData,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: context.l10n.commonSettings,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- VOCI DI MENU ---
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: effectivelyCollapsed ? 72 : 260,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
children: [
|
||||
_buildRouteItem(
|
||||
title: context.l10n.commonDashboard,
|
||||
icon: Icons.dashboard_outlined,
|
||||
routeName: Routes.home, // <--- Usiamo la tua costante!
|
||||
pathToCheck:
|
||||
'/', // Il path da controllare per colorarlo
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
|
||||
_buildHierarchicalItem(
|
||||
title: context.l10n.commonMasterData,
|
||||
icon: Icons.folder_special_outlined,
|
||||
basePathToCheck:
|
||||
'/master-data', // Se il path inizia così, espandi
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
subItems: [
|
||||
_SubMenuItem(
|
||||
"Clienti",
|
||||
Routes.customers,
|
||||
'/master-data/customers',
|
||||
),
|
||||
_SubMenuItem(
|
||||
"Fornitori",
|
||||
Routes.providers,
|
||||
'/master-data/providers',
|
||||
),
|
||||
_SubMenuItem(
|
||||
"Prodotti",
|
||||
Routes.products,
|
||||
'/master-data/products',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildRouteItem(
|
||||
title: context.l10n.commonSettings,
|
||||
icon: Icons.settings_outlined,
|
||||
routeName: Routes.settings,
|
||||
pathToCheck: '/settings',
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- PULSANTE TOGGLE (Solo Desktop) ---
|
||||
if (!widget.isDrawer)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton(
|
||||
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
|
||||
icon: Icon(
|
||||
_isCollapsed
|
||||
? Icons.keyboard_double_arrow_right
|
||||
: Icons.keyboard_double_arrow_left,
|
||||
color: theme.iconTheme.color?.withValues(alpha: 0.5),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCollapsed = !_isCollapsed;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WIDGET HELPER AGGIORNATI
|
||||
// ==========================================
|
||||
|
||||
Widget _buildRouteItem({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String routeName,
|
||||
required String pathToCheck,
|
||||
required bool isCollapsed,
|
||||
}) {
|
||||
final isSelected = widget.currentPath == pathToCheck;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (isCollapsed) {
|
||||
return Tooltip(
|
||||
message: title,
|
||||
preferBelow: false,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
selected: isSelected,
|
||||
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHierarchicalItem({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String basePathToCheck,
|
||||
required bool isCollapsed,
|
||||
required List<_SubMenuItem> subItems,
|
||||
}) {
|
||||
final isSelected = widget.currentPath.startsWith(basePathToCheck);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (isCollapsed) {
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: title,
|
||||
offset: const Offset(60, 0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (routeName) {
|
||||
// Il routeName arriva dal value del menu
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
itemBuilder: (context) => subItems
|
||||
.map(
|
||||
(item) => PopupMenuItem(
|
||||
value: item
|
||||
.routeName, // Passiamo il nome della rotta (Routes.customers)
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: widget.currentPath == item.pathToCheck
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: widget.currentPath == item.pathToCheck
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: isSelected,
|
||||
maintainState: true,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
children: subItems.map((item) {
|
||||
final isSubSelected = widget.currentPath == item.pathToCheck;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
selected: isSubSelected,
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSubSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isSubSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.textTheme.bodyMedium?.color,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(item.routeName); // <--- goNamed!
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Struttura dati per le voci dei sottomenu aggiornata
|
||||
class _SubMenuItem {
|
||||
final String title;
|
||||
final String routeName; // Es: Routes.customers
|
||||
final String pathToCheck; // Es: '/master-data/customers'
|
||||
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
|
||||
}
|
||||
|
||||
@@ -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,9 +28,14 @@ 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';
|
||||
import 'package:flux/features/notes/ui/notes_list_screen.dart';
|
||||
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
||||
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||
@@ -39,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';
|
||||
@@ -123,83 +134,140 @@ 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,
|
||||
builder: (context, state) => const TicketListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notes',
|
||||
name: Routes.notes,
|
||||
builder: (context, state) => const NotesListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks',
|
||||
name: Routes.tasks,
|
||||
builder: (context, state) {
|
||||
// 1. Recuperiamo lo stato della sessione per le dipendenze
|
||||
final sessionState = context.read<SessionCubit>().state;
|
||||
|
||||
// Sicurezza: Se per qualche motivo non abbiamo l'azienda,
|
||||
// qui potresti reindirizzare o gestire l'errore
|
||||
final companyId = sessionState.company?.id;
|
||||
if (companyId == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text("Errore: Azienda non trovata")),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Iniettiamo il Cubit con tutto ciò che gli serve
|
||||
return BlocProvider(
|
||||
create: (context) => TaskListCubit(
|
||||
currentCompanyId: companyId,
|
||||
currentStoreId: sessionState
|
||||
.currentStore
|
||||
?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store
|
||||
),
|
||||
child: const TaskListScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -436,6 +504,61 @@ class AppRouter {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notes/edit/:id',
|
||||
name: Routes.noteForm,
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
final NoteModel note = state.extra as NoteModel;
|
||||
|
||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AttachmentsBloc>(
|
||||
create: (context) => AttachmentsBloc(
|
||||
parentId: id,
|
||||
parentType: AttachmentParentType.note,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
child: NoteFormScreen(note: note),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/form/:id',
|
||||
name: Routes.taskForm,
|
||||
builder: (context, state) {
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final TaskModel? task = state.extra as TaskModel?;
|
||||
final String? realTaskId;
|
||||
if (pathId == 'new') {
|
||||
realTaskId = null;
|
||||
} else if (task?.id != null) {
|
||||
realTaskId = task!.id;
|
||||
} else {
|
||||
realTaskId = pathId;
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,4 +22,8 @@ class Routes {
|
||||
static const String customerDetails = 'customer-details';
|
||||
static const String upload = 'upload';
|
||||
static const String ticketWorkspace = 'ticket-workspace';
|
||||
static const String noteForm = 'note-form';
|
||||
static const String notes = 'notes';
|
||||
static const String tasks = 'tasks';
|
||||
static const String taskForm = 'task-form';
|
||||
}
|
||||
|
||||
18
lib/core/utils/debouncer.dart
Normal file
18
lib/core/utils/debouncer.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Debouncer {
|
||||
final int milliseconds;
|
||||
Timer? _timer;
|
||||
|
||||
Debouncer({required this.milliseconds});
|
||||
|
||||
void run(VoidCallback action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Duration(milliseconds: milliseconds), action);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
@@ -24,14 +25,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
void _submit() async {
|
||||
// Chiudiamo la tastiera per fare pulizia a schermo
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
context.read<AuthCubit>().submitAuth(
|
||||
final isSuccess = await context.read<AuthCubit>().submitAuth(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
TextInput.finishAutofillContext();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,6 +74,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -106,6 +112,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.email_outlined,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.email,
|
||||
AutofillHints.username,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FluxTextField(
|
||||
@@ -113,6 +123,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true, // Magia del FluxTextField!
|
||||
controller: _passwordController,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onSubmitted: (_) =>
|
||||
_submit(), // Se lo supporti nel tuo widget custom
|
||||
),
|
||||
@@ -175,9 +186,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (state.isLoginMode) ...[
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => context
|
||||
.read<AuthCubit>()
|
||||
.requestPasswordReset(_emailController.text.trim()),
|
||||
onPressed: () =>
|
||||
context.read<AuthCubit>().requestPasswordReset(
|
||||
_emailController.text.trim(),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.authScreenForgotPassword,
|
||||
style: TextStyle(
|
||||
@@ -191,6 +203,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -50,7 +50,7 @@ class CustomerRepository {
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
.order('name', ascending: true);
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} catch (e) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'dashboard_task_list_cubit.dart';
|
||||
|
||||
enum DashboardTaskListStatus { initial, loading, success, failure }
|
||||
|
||||
class DashboardTaskListState extends Equatable {
|
||||
final DashboardTaskListStatus status;
|
||||
final List<TaskModel> tasks;
|
||||
final String? errorMessage;
|
||||
|
||||
const DashboardTaskListState({
|
||||
this.status = DashboardTaskListStatus.initial,
|
||||
this.tasks = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DashboardTaskListState copyWith({
|
||||
DashboardTaskListStatus? status,
|
||||
List<TaskModel>? tasks,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DashboardTaskListState(
|
||||
status: status ?? this.status,
|
||||
tasks: tasks ?? this.tasks,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, tasks, errorMessage];
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,13 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(Routes.operations),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -53,6 +53,5 @@ class LatestStoreTicketsBloc
|
||||
);
|
||||
}
|
||||
});
|
||||
// TODO: implement event handlers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,16 @@ 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';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@@ -62,26 +67,16 @@ class HomeScreen extends StatelessWidget {
|
||||
childAspectRatio: 1.3,
|
||||
),
|
||||
delegate: SliverChildListDelegate([
|
||||
LatestStoreOperationsCard(),
|
||||
LatestStoreTicketsCard(),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.homeExpiringContracts,
|
||||
icon: Icons.assignment_late_outlined,
|
||||
color: Colors.orange,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.commonStickyNotes,
|
||||
icon: Icons.sticky_note_2_outlined,
|
||||
color: Colors.yellow.shade700,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.homeMyTasks,
|
||||
icon: Icons.check_box_outlined,
|
||||
color: Colors.green,
|
||||
context: context,
|
||||
),
|
||||
LatestStoreOperationsCard(),
|
||||
LatestStoreTicketsCard(),
|
||||
DashboardNotesWidget(),
|
||||
DashboardTasksCard(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -211,8 +206,26 @@ class HomeScreen extends StatelessWidget {
|
||||
icon: Icons.note_add,
|
||||
label: context.l10n.commonNote,
|
||||
color: Colors.amber,
|
||||
onTap: () {
|
||||
// TODO: Quando faremo il modale/pagina delle note
|
||||
onTap: () async {
|
||||
final companyId = context.read<SessionCubit>().state.company!.id!;
|
||||
final currentStaffId = context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!
|
||||
.id!;
|
||||
final emptyNote = NoteModel.empty(
|
||||
createdBy: currentStaffId,
|
||||
companyId: companyId,
|
||||
);
|
||||
final noteId = await GetIt.I.get<NotesRepository>().saveNote(
|
||||
emptyNote,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: {'id': noteId},
|
||||
extra: emptyNote.copyWith(id: noteId),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -221,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'});
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,13 +13,36 @@ 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);
|
||||
|
||||
return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList();
|
||||
}
|
||||
|
||||
Future<StaffMemberModel?> getStaffMemberById(String staffId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from(Tables.staffMembers)
|
||||
.select('''
|
||||
*,
|
||||
store_assignments:${Tables.staffInStores} (
|
||||
${Tables.stores}(*)
|
||||
)
|
||||
''')
|
||||
.eq('id', staffId)
|
||||
.single();
|
||||
return StaffMemberModel.fromMap(response);
|
||||
} on Exception catch (e) {
|
||||
throw ('Errore nel recupero del membro staff con ID $staffId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StaffMemberModel> saveStaffMember(StaffMemberModel member) async {
|
||||
final response = await _supabase
|
||||
.from(Tables.staffMembers)
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
if (member.jobTitle != null &&
|
||||
member.jobTitle!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Qualifica: ${member.jobTitle!}',
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// 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: [
|
||||
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,
|
||||
Icon(
|
||||
!member.hasJoined ? Icons.send : Icons.lock_reset,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
// Chiama la funzione di reset password mascherata da invito
|
||||
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
|
||||
member.email!,
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
!member.hasJoined
|
||||
? "Re-invia Invito"
|
||||
: "Reset Password",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
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!,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
: 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!);
|
||||
},
|
||||
), */
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,10 +67,13 @@ 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),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -90,21 +95,17 @@ class _StoreCardState extends State<StoreCard> {
|
||||
label: Text(
|
||||
"${widget.store.associatedProviders.length} Providers",
|
||||
),
|
||||
onPressed: () => _manageStoreProviders(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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
80
lib/features/notes/blocs/notes_bloc.dart
Normal file
80
lib/features/notes/blocs/notes_bloc.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
part 'notes_event.dart';
|
||||
part 'notes_state.dart';
|
||||
|
||||
class NotesBloc extends Bloc<NotesEvent, NotesState> {
|
||||
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
|
||||
final String _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||
final String _currentStaffId = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!
|
||||
.id!;
|
||||
|
||||
NotesBloc() : super(const NotesState()) {
|
||||
on<SubscribeToNotesRequested>(_onSubscribeToNotesRequested);
|
||||
on<NoteSavedRequested>(_onNoteSavedRequested);
|
||||
on<NoteDeletedRequested>(_onNoteDeletedRequested);
|
||||
|
||||
// Facciamo partire l'ascolto in tempo reale al boot del BLoC
|
||||
add(SubscribeToNotesRequested());
|
||||
}
|
||||
|
||||
Future<void> _onSubscribeToNotesRequested(
|
||||
SubscribeToNotesRequested event,
|
||||
Emitter<NotesState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: NotesStatus.loading));
|
||||
|
||||
// Usiamo l'emit.forEach sullo stream pulito del repository
|
||||
await emit.forEach<List<NoteModel>>(
|
||||
_repository.notesStream(
|
||||
companyId: _companyId,
|
||||
currentStaffId: _currentStaffId,
|
||||
),
|
||||
onData: (notesList) {
|
||||
return state.copyWith(status: NotesStatus.success, notes: notesList);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
return state.copyWith(
|
||||
status: NotesStatus.failure,
|
||||
errorMessage: 'Errore nello stream realtime: $error',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onNoteSavedRequested(
|
||||
NoteSavedRequested event,
|
||||
Emitter<NotesState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.saveNote(event.note);
|
||||
// Non serve fare l'emit! Ci pensa lo stream a far rimbalzare i dati aggiornati
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onNoteDeletedRequested(
|
||||
NoteDeletedRequested event,
|
||||
Emitter<NotesState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.deleteNote(event.noteId);
|
||||
// Anche qui, lo stream rileva la cancellazione in automatico
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
lib/features/notes/blocs/notes_event.dart
Normal file
17
lib/features/notes/blocs/notes_event.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
part of 'notes_bloc.dart';
|
||||
|
||||
sealed class NotesEvent {}
|
||||
|
||||
/// Fa partire lo stream e gestisce sia il caricamento iniziale che il realtime
|
||||
class SubscribeToNotesRequested extends NotesEvent {}
|
||||
|
||||
class NoteDeletedRequested extends NotesEvent {
|
||||
final String noteId;
|
||||
NoteDeletedRequested(this.noteId);
|
||||
}
|
||||
|
||||
/// Salva o aggiorna una nota
|
||||
class NoteSavedRequested extends NotesEvent {
|
||||
final NoteModel note;
|
||||
NoteSavedRequested(this.note);
|
||||
}
|
||||
39
lib/features/notes/blocs/notes_state.dart
Normal file
39
lib/features/notes/blocs/notes_state.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
part of 'notes_bloc.dart';
|
||||
|
||||
enum NotesStatus { initial, loading, success, failure }
|
||||
|
||||
class NotesState {
|
||||
final NotesStatus status;
|
||||
final List<NoteModel> notes;
|
||||
final String? errorMessage;
|
||||
|
||||
const NotesState({
|
||||
this.status = NotesStatus.initial,
|
||||
this.notes = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
NotesState copyWith({
|
||||
NotesStatus? status,
|
||||
List<NoteModel>? notes,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return NotesState(
|
||||
status: status ?? this.status,
|
||||
notes: notes ?? this.notes,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is NotesState &&
|
||||
other.status == status &&
|
||||
listEquals(other.notes, notes) &&
|
||||
other.errorMessage == errorMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => status.hashCode ^ notes.hashCode ^ errorMessage.hashCode;
|
||||
}
|
||||
150
lib/features/notes/data/notes_repository.dart
Normal file
150
lib/features/notes/data/notes_repository.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class NotesRepository {
|
||||
final _supabase = GetIt.I.get<SupabaseClient>();
|
||||
|
||||
String get _companyId => GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||
String get _currentStaffId =>
|
||||
GetIt.I.get<SessionCubit>().state.currentStaffMember!.id!;
|
||||
|
||||
/// Recupera tutte le note visibili dall'utente corrente:
|
||||
/// 1. Note create da lui
|
||||
/// 2. Note condivise a tutti (is_shared_all = true)
|
||||
/// 3. Note in cui l'utente è esplicitamente un collaboratore
|
||||
Future<List<NoteModel>> getNotes() async {
|
||||
try {
|
||||
// Usiamo la sintassi avanzata di Supabase per fare il filtro OR sulle relazioni
|
||||
// Inoltre tiriamo giù note_collaborators e l'anagrafica dei membri dello staff associati
|
||||
final response = await _supabase
|
||||
.from(Tables.notes)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.noteCollaborators}!inner (
|
||||
staff_id,
|
||||
${Tables.staffMembers} (*)
|
||||
)
|
||||
''')
|
||||
// Filtro multi-tenant di sicurezza (già ridondante con RLS ma ottimo per performance)
|
||||
.eq('company_id', _companyId)
|
||||
// Questa è la magia: l'utente vede la nota se è sua, se è pubblica o se è tra i collaboratori
|
||||
.or(
|
||||
'created_by.eq.$_currentStaffId,is_shared_all.eq.true,note_collaborators.staff_id.eq.$_currentStaffId',
|
||||
)
|
||||
// Ordiniamo prima per sticky (pinned) e poi per data di aggiornamento
|
||||
.order('is_pinned', ascending: false)
|
||||
.order('updated_at', ascending: false);
|
||||
|
||||
return (response as List)
|
||||
.map((json) => NoteModel.fromMap(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// In caso di errore sulla join !inner se non ci sono collaboratori,
|
||||
// facciamo un fallback pulito su una query standard e uniamo i dati.
|
||||
return _getNotesFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback sicuro nel caso la query complessa con !inner si inceppi se la lista collaboratori è vuota
|
||||
Future<List<NoteModel>> _getNotesFallback() async {
|
||||
final response = await _supabase
|
||||
.from(Tables.notes)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.noteCollaborators} (
|
||||
staff_id,
|
||||
${Tables.staffMembers} (*)
|
||||
)
|
||||
''')
|
||||
.eq('company_id', _companyId)
|
||||
.order('is_pinned', ascending: false)
|
||||
.order('updated_at', ascending: false);
|
||||
|
||||
final allNotes = (response as List)
|
||||
.map((json) => NoteModel.fromMap(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Filtriamo lato codice per essere sicuri della visibilità
|
||||
return allNotes.where((note) {
|
||||
return note.createdBy == _currentStaffId ||
|
||||
note.isSharedAll ||
|
||||
note.collaboratorIds.contains(_currentStaffId);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Stream<List<NoteModel>> notesStream({
|
||||
required String companyId,
|
||||
required String currentStaffId,
|
||||
}) {
|
||||
return _supabase
|
||||
.from(Tables.notes)
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('company_id', companyId)
|
||||
.order('is_pinned', ascending: false)
|
||||
// Nota: puoi ordinare solo per un campo alla volta nello stream nativo di Supabase.
|
||||
// Ordiniamo per is_pinned, l'ordinamento per data lo facciamo al volo in RAM se serve,
|
||||
// oppure ordiniamo per updated_at e il pin lo gestiamo via software.
|
||||
.map((rawNotes) {
|
||||
return rawNotes.map((json) => NoteModel.fromMap(json)).where((note) {
|
||||
// Filtro multi-tenant di sicurezza in memoria
|
||||
return note.createdBy == currentStaffId ||
|
||||
note.isSharedAll ||
|
||||
note.collaboratorIds.contains(currentStaffId);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> saveNote(NoteModel note) async {
|
||||
// 1. Eseguiamo l'upsert sulla tabella principale 'notes'
|
||||
// Supabase gestisce in automatico: se l'id è null inserisce, se c'è fa update.
|
||||
// Usiamo il .select().single() per farci restituire l'id generato (in caso di nuova nota)
|
||||
final noteResponse = await _supabase
|
||||
.from(Tables.notes)
|
||||
.upsert({
|
||||
if (note.id != null) 'id': note.id,
|
||||
'company_id': note.companyId,
|
||||
'created_by': note.createdBy,
|
||||
'title': note.title,
|
||||
'content': note.content,
|
||||
'color': note.color,
|
||||
'is_pinned': note.isPinned,
|
||||
'is_shared_all': note.isSharedAll,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
final noteId = noteResponse['id'] as String;
|
||||
|
||||
// Se la nota è condivisa con tutti, spazziamo via eventuali collaboratori singoli e usciamo
|
||||
if (note.isSharedAll) {
|
||||
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
|
||||
return noteId;
|
||||
}
|
||||
|
||||
// 2. LA STRATEGIA DEL PIAZZA PULITA SUI COLLABORATORI
|
||||
// Prima di tutto eliminiamo TUTTI i collaboratori attuali per questa specifica nota
|
||||
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
|
||||
|
||||
// 3. RE-INSERIMENTO DELLA LISTA AGGIORNATA
|
||||
// Se ci sono collaboratori da inserire, li prepariamo in blocco (Bulk Insert)
|
||||
if (note.collaboratorIds.isNotEmpty) {
|
||||
final collaboratorsToInsert = note.collaboratorIds
|
||||
.map((staffId) => {'note_id': noteId, 'staff_id': staffId})
|
||||
.toList();
|
||||
|
||||
await _supabase.from('note_collaborators').insert(collaboratorsToInsert);
|
||||
}
|
||||
|
||||
// Restituiamo l'id alla UI (fondamentale per la nostra logica Ninja di creazione)
|
||||
return noteId;
|
||||
}
|
||||
|
||||
/// Elimina una nota (i collaboratori si cancellano in cascata grazie al vincolo del DB)
|
||||
Future<void> deleteNote(String noteId) async {
|
||||
await _supabase.from(Tables.notes).delete().eq('id', noteId);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,34 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
|
||||
class NoteModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final String companyId;
|
||||
final String createdBy;
|
||||
final String? title;
|
||||
final String? content;
|
||||
final String color; // Stringa Hex es. '#FFF59D'
|
||||
final String color;
|
||||
final bool isPinned;
|
||||
final bool isSharedAll;
|
||||
final String companyId;
|
||||
final List<String> collaboratorIds;
|
||||
|
||||
// Campi di utilità per la UI e le relazioni
|
||||
final List<String> collaboratorIds;
|
||||
final List<StaffMemberModel> collaborators;
|
||||
//final List<StaffMemberModel> collaborators;
|
||||
|
||||
const NoteModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
required this.companyId,
|
||||
required this.createdBy,
|
||||
this.title,
|
||||
this.content,
|
||||
this.color = '#FFF59D', // Giallo Post-it di default
|
||||
this.color = '#FFF59D',
|
||||
this.isPinned = false,
|
||||
this.isSharedAll = false,
|
||||
required this.companyId,
|
||||
this.collaboratorIds = const [],
|
||||
this.collaborators = const [],
|
||||
required this.collaboratorIds,
|
||||
});
|
||||
|
||||
/// Trasforma il colore Hex String in un oggetto Color di Flutter
|
||||
@@ -43,29 +41,27 @@ class NoteModel extends Equatable {
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? companyId,
|
||||
String? createdBy,
|
||||
String? title,
|
||||
String? content,
|
||||
String? color,
|
||||
bool? isPinned,
|
||||
bool? isSharedAll,
|
||||
String? companyId,
|
||||
List<String>? collaboratorIds,
|
||||
List<StaffMemberModel>? collaborators,
|
||||
}) {
|
||||
return NoteModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
companyId: companyId ?? this.companyId,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
color: color ?? this.color,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
isSharedAll: isSharedAll ?? this.isSharedAll,
|
||||
companyId: companyId ?? this.companyId,
|
||||
collaboratorIds: collaboratorIds ?? this.collaboratorIds,
|
||||
collaborators: collaborators ?? this.collaborators,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +69,11 @@ class NoteModel extends Equatable {
|
||||
required String createdBy,
|
||||
required String companyId,
|
||||
}) {
|
||||
return NoteModel(createdBy: createdBy, companyId: companyId);
|
||||
return NoteModel(
|
||||
createdBy: createdBy,
|
||||
companyId: companyId,
|
||||
collaboratorIds: [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
@@ -91,27 +91,6 @@ class NoteModel extends Equatable {
|
||||
}
|
||||
|
||||
factory NoteModel.fromMap(Map<String, dynamic> map) {
|
||||
// Estraiamo gli ID dei collaboratori se presenti dalla join nativa di Supabase
|
||||
List<String> collIds = [];
|
||||
List<StaffMemberModel> collModels = [];
|
||||
|
||||
if (map['note_collaborators'] != null) {
|
||||
final List jsonList = map['note_collaborators'] as List;
|
||||
for (var item in jsonList) {
|
||||
if (item['staff_id'] != null) {
|
||||
collIds.add(item['staff_id'].toString());
|
||||
}
|
||||
// Se abbiamo fatto la join profonda per avere anche i dettagli dello staff member
|
||||
if (item['staff_members'] != null) {
|
||||
collModels.add(
|
||||
StaffMemberModel.fromMap(
|
||||
item['staff_members'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NoteModel(
|
||||
id: map['id'] as String?,
|
||||
createdAt: map['created_at'] != null
|
||||
@@ -120,15 +99,17 @@ class NoteModel extends Equatable {
|
||||
updatedAt: map['updated_at'] != null
|
||||
? DateTime.parse(map['updated_at'] as String)
|
||||
: null,
|
||||
companyId: map['company_id'] as String,
|
||||
createdBy: map['created_by'] as String,
|
||||
title: map['title'] as String?,
|
||||
content: map['content'] as String?,
|
||||
color: map['color'] as String? ?? '#FFF59D',
|
||||
isPinned: map['is_pinned'] as bool? ?? false,
|
||||
isSharedAll: map['is_shared_all'] as bool? ?? false,
|
||||
companyId: map['company_id'] as String,
|
||||
collaboratorIds: collIds,
|
||||
collaborators: collModels,
|
||||
// TRUCCO NINJA: Castiamo l'array di Postgres in una List<String> pulita
|
||||
collaboratorIds: map['collaborator_ids'] != null
|
||||
? List<String>.from(map['collaborator_ids'] as List)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +126,6 @@ class NoteModel extends Equatable {
|
||||
isSharedAll,
|
||||
companyId,
|
||||
collaboratorIds,
|
||||
collaborators,
|
||||
//collaborators,
|
||||
];
|
||||
}
|
||||
|
||||
197
lib/features/notes/ui/dashboard_notes_widget.dart
Normal file
197
lib/features/notes/ui/dashboard_notes_widget.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/notes/blocs/notes_bloc.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
|
||||
|
||||
class DashboardNotesWidget extends StatelessWidget {
|
||||
const DashboardNotesWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(Routes.notes),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Intestazione del riquadro
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.yellow.shade700.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sticky_note_2_outlined,
|
||||
size: 16,
|
||||
color: Colors.yellow.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Le mie Note',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Il corpo del widget collegato al Bloc
|
||||
BlocBuilder<NotesBloc, NotesState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotesStatus.loading &&
|
||||
state.notes.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == NotesStatus.failure) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Errore nel caricamento delle note.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.notes.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
|
||||
// Prendiamo solo le prime 4 note per non intaccare troppo spazio in Dashboard
|
||||
final displayNotes = state.notes.take(4).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: 140, // Altezza fissa per lo scroll orizzontale
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: displayNotes.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildMiniPostIt(context, displayNotes[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniPostIt(BuildContext context, NoteModel note) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Vai al form di dettaglio passando l'ID o l'oggetto
|
||||
context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: {'id': note.id!},
|
||||
extra: note,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.only(right: 12, bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: note.flutterColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(2, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title?.isNotEmpty == true
|
||||
? note.title!
|
||||
: 'Senza titolo',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (note.isPinned)
|
||||
const Icon(Icons.push_pin, size: 14, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.content ?? '',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black87),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.sticky_note_2_outlined,
|
||||
size: 32,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Nessuna nota presente.'),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/notes/create'),
|
||||
child: const Text('Creane una ora'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
lib/features/notes/ui/notes_form_screen.dart
Normal file
492
lib/features/notes/ui/notes_form_screen.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/debouncer.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/notes/blocs/notes_bloc.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
|
||||
class NoteFormScreen extends StatefulWidget {
|
||||
final NoteModel
|
||||
note; // La nota DEVE essere già stata creata (anche vuota) dal DB prima di arrivare qui
|
||||
|
||||
const NoteFormScreen({super.key, required this.note});
|
||||
|
||||
@override
|
||||
State<NoteFormScreen> createState() => _NoteFormScreenState();
|
||||
}
|
||||
|
||||
class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
NoteModel get _note => widget.note;
|
||||
late TextEditingController _titleController;
|
||||
late TextEditingController _contentController;
|
||||
late final NotesBloc _notesBloc;
|
||||
late String _selectedColor;
|
||||
late bool _isPinned;
|
||||
late bool _isSharedAll;
|
||||
late List<String> _selectedStaffIds;
|
||||
|
||||
// Inizializziamo il Debouncer a 500 millisecondi
|
||||
final _debouncer = Debouncer(milliseconds: 500);
|
||||
|
||||
final List<String> _noteColors = [
|
||||
'#FFF59D',
|
||||
'#FFCDD2',
|
||||
'#C8E6C9',
|
||||
'#BBDEFB',
|
||||
'#E1BEE7',
|
||||
'#F5F5F5',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.note.title ?? '');
|
||||
_contentController = TextEditingController(text: widget.note.content ?? '');
|
||||
_notesBloc = context.read<NotesBloc>();
|
||||
_selectedColor = widget.note.color;
|
||||
_isPinned = widget.note.isPinned;
|
||||
_isSharedAll = widget.note.isSharedAll;
|
||||
_selectedStaffIds = List.from(widget.note.collaboratorIds);
|
||||
|
||||
// Mettiamo i controller in ascolto per scatenare l'auto-salvataggio
|
||||
_titleController.addListener(_onFieldsChanged);
|
||||
_contentController.addListener(_onFieldsChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.removeListener(_onFieldsChanged);
|
||||
_contentController.removeListener(_onFieldsChanged);
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
_debouncer.dispose();
|
||||
|
||||
// --- IL BOTTO FINALE: PULIZIA SILENZIOSA ---
|
||||
_checkAndCleanupIfEmpty(_note.id!);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Chiamata ogni volta che l'utente digita una lettera
|
||||
void _onFieldsChanged() {
|
||||
_debouncer.run(() => _triggerAutoSave());
|
||||
}
|
||||
|
||||
/// Salva in background notificando il BLoC (così la bacheca si aggiorna in tempo reale)
|
||||
void _triggerAutoSave() {
|
||||
final companyId = context.read<SessionCubit>().state.company!.id!;
|
||||
|
||||
final updatedNote = NoteModel(
|
||||
id: widget.note.id,
|
||||
createdBy: widget.note.createdBy,
|
||||
companyId: companyId,
|
||||
title: _titleController.text.trim(),
|
||||
content: _contentController.text.trim(),
|
||||
color: _selectedColor,
|
||||
isPinned: _isPinned,
|
||||
isSharedAll: _isSharedAll,
|
||||
collaboratorIds: _selectedStaffIds,
|
||||
);
|
||||
|
||||
// Spariamo l'evento al Bloc, che salverà silente sul DB tramite Repository
|
||||
_notesBloc.add(NoteSavedRequested(updatedNote));
|
||||
}
|
||||
|
||||
/// Se l'utente esce e la nota è totalmente vuota, la eliminiamo dal DB "al secchio"
|
||||
void _checkAndCleanupIfEmpty(String noteId) {
|
||||
final titleEmpty = _titleController.text.trim().isEmpty;
|
||||
final contentEmpty = _contentController.text.trim().isEmpty;
|
||||
|
||||
//Se hai un modo per verificare se ci sono allegati associati (es. tramite una query locale o un contatore),
|
||||
// assicurati che non ce ne siano prima di eliminare.
|
||||
// Assumiamo che se non ha scritto testo ed è appena stata creata, sia vuota.
|
||||
if (titleEmpty && contentEmpty) {
|
||||
// Notifichiamo anche il Bloc dell'avvenuta cancellazione così pulisce lo stato locale
|
||||
_notesBloc.add(NoteDeletedRequested(noteId));
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteNote() {
|
||||
_notesBloc.add(NoteDeletedRequested(widget.note.id!));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _exportNote() {
|
||||
// La logica di export che abbiamo concordato ieri!
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Funzione di esportazione/stampa in arrivo! 🚀'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStaffPickerBottomSheet() {
|
||||
final allStaff = context.read<StaffCubit>().state.storeStaff;
|
||||
// Recuperiamo l'ID del creatore della nota
|
||||
final creatorId = widget.note.createdBy;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Seleziona Collaboratori',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: allStaff.length,
|
||||
itemBuilder: (context, index) {
|
||||
final staff = allStaff[index];
|
||||
|
||||
// Capiamo se questo membro dello staff è il creatore
|
||||
final isCreator = staff.id == creatorId;
|
||||
// È spuntato se è il creatore OPPURE se è nella lista dei collaboratori
|
||||
final isSelected =
|
||||
isCreator || _selectedStaffIds.contains(staff.id);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
children: [
|
||||
TextSpan(text: staff.name),
|
||||
if (isCreator)
|
||||
const TextSpan(
|
||||
text: ' (Proprietario)',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
value: isSelected,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
// IL TRUCCO NINJA: se è il creatore, passiamo null per disabilitare la spunta!
|
||||
onChanged: isCreator
|
||||
? null
|
||||
: (bool? value) {
|
||||
setModalState(() {
|
||||
if (value == true) {
|
||||
_selectedStaffIds.add(staff.id!);
|
||||
} else {
|
||||
_selectedStaffIds.remove(staff.id!);
|
||||
}
|
||||
});
|
||||
setState(() {});
|
||||
_triggerAutoSave();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fatto'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final noteColor = Color(
|
||||
int.parse('FF${_selectedColor.replaceAll('#', '')}', radix: 16),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
// 1. Sfondo scuro e riposante per l'intera schermata
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
// Freccia indietro chiara per fare contrasto sullo sfondo grigio scuro
|
||||
iconTheme: const IconThemeData(color: Colors.white70),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 680),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 64, left: 16, right: 16),
|
||||
child: Container(
|
||||
// 2. IL NOSTRO POST-IT FISICO
|
||||
decoration: BoxDecoration(
|
||||
color: noteColor,
|
||||
borderRadius: BorderRadius.circular(16), // Bordi arrotondati
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4), // Ombra morbida
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
// 3. MainAxisSize.min fa sì che il Post-it sia alto solo quanto serve!
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 1. Capiamo quanto spazio reale ha la finestra in questo momento
|
||||
final isNarrow = constraints.maxWidth < 500;
|
||||
|
||||
// 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
|
||||
? 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();
|
||||
},
|
||||
),
|
||||
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),
|
||||
|
||||
// --- TITOLO ---
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Titolo...',
|
||||
hintStyle: TextStyle(color: Colors.black38),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
|
||||
// --- CONTENUTO ---
|
||||
TextFormField(
|
||||
controller: _contentController,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.black87,
|
||||
height: 1.5,
|
||||
),
|
||||
maxLines: null,
|
||||
minLines: 12,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Scrivi qui la tua nota...',
|
||||
hintStyle: TextStyle(color: Colors.black38),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Divider(color: Colors.black12),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- CONDIVISIONE ---
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Condividi con tutti',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
value: _isSharedAll,
|
||||
activeThumbColor: Colors.black87,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isSharedAll = val;
|
||||
if (val) _selectedStaffIds.clear();
|
||||
});
|
||||
_triggerAutoSave();
|
||||
},
|
||||
),
|
||||
|
||||
if (!_isSharedAll) ...[
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: const Icon(
|
||||
Icons.add,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: const Text(
|
||||
'Aggiungi Colleghi',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onPressed: _showStaffPickerBottomSheet,
|
||||
// Pesca in automatico il blu dei tuoi pulsanti Salva!
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
side: BorderSide
|
||||
.none, // Togliamo il bordino per un look più pulito e solido
|
||||
elevation:
|
||||
2, // Una leggera ombra per farlo sembrare cliccabile
|
||||
),
|
||||
if (_selectedStaffIds.isNotEmpty)
|
||||
Chip(
|
||||
label: Text(
|
||||
'${_selectedStaffIds.length} collaboratori',
|
||||
style: const TextStyle(color: Colors.black87),
|
||||
),
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
deleteIconColor: Colors.black87,
|
||||
side: const BorderSide(color: Colors.black12),
|
||||
onDeleted: () {
|
||||
setState(() => _selectedStaffIds.clear());
|
||||
_triggerAutoSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Divider(color: Colors.black12),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// --- ALLEGATI ---
|
||||
// SharedAttachmentsSection(
|
||||
// parentType: AttachmentParentType.note,
|
||||
// parentId: widget.note.id!,
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
249
lib/features/notes/ui/notes_list_screen.dart
Normal file
249
lib/features/notes/ui/notes_list_screen.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/features/notes/blocs/notes_bloc.dart';
|
||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
|
||||
class NotesListScreen extends StatelessWidget {
|
||||
const NotesListScreen({super.key});
|
||||
|
||||
/// Logica Ninja: Crea la nota vuota, prende l'ID, e apre il form
|
||||
Future<void> _createNewNoteAndNavigate(BuildContext context) async {
|
||||
final sessionState = context.read<SessionCubit>().state;
|
||||
final companyId = sessionState.company!.id!;
|
||||
final currentStaffId = sessionState.currentStaffMember!.id!;
|
||||
|
||||
// 1. Creiamo la nota vuota
|
||||
final emptyNote = NoteModel.empty(
|
||||
createdBy: currentStaffId,
|
||||
companyId: companyId,
|
||||
).copyWith(color: '#FFF59D');
|
||||
|
||||
// Mostriamo un loading veloce se serve (opzionale)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Creazione nota in corso...'),
|
||||
duration: Duration(milliseconds: 500),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// 2. Scriviamo su DB per avere l'ID
|
||||
final noteId = await GetIt.I.get<NotesRepository>().saveNote(emptyNote);
|
||||
if (context.mounted) {
|
||||
// 3. Spingiamo l'utente nel form con la nota già provvista di ID!
|
||||
context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: ({'id': noteId}),
|
||||
extra: emptyNote.copyWith(id: noteId),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Errore: Impossibile creare la nota. $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor:
|
||||
Colors.grey.shade900, // Sfondo neutro per far risaltare i post-it
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Bacheca Note',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
centerTitle: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createNewNoteAndNavigate(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nuova Nota'),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: BlocBuilder<NotesBloc, NotesState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotesStatus.loading && state.notes.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == NotesStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Errore nel caricamento: ${state.errorMessage}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.notes.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sticky_note_2_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Nessuna nota presente.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Clicca su "Nuova Nota" per iniziare.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ordiniamo le note: prima le pinnate, poi le altre (se non le ordina già il DB)
|
||||
final sortedNotes = List<NoteModel>.from(state.notes)
|
||||
..sort((a, b) {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0; // Se vuoi puoi aggiungere l'ordinamento per data qui
|
||||
});
|
||||
|
||||
return _buildMasonryGrid(context, sortedNotes);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMasonryGrid(BuildContext context, List<NoteModel> notes) {
|
||||
// Calcoliamo quante colonne mostrare in base alla larghezza dello schermo
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
int crossAxisCount = 2; // Mobile
|
||||
if (screenWidth > 600) crossAxisCount = 3; // Tablet
|
||||
if (screenWidth > 900) crossAxisCount = 4; // Desktop piccolo
|
||||
if (screenWidth > 1200) crossAxisCount = 5; // Desktop grande
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: MasonryGridView.count(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
itemCount: notes.length,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80,
|
||||
top: 8,
|
||||
), // Spazio per non coprire col FAB
|
||||
itemBuilder: (context, index) {
|
||||
final note = notes[index];
|
||||
return _buildNoteCard(context, note);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoteCard(BuildContext context, NoteModel note) {
|
||||
final noteColor = Color(
|
||||
int.parse('FF${note.color.replaceAll('#', '')}', radix: 16),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: {'id': note.id!},
|
||||
extra: note,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: noteColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(2, 2),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.05)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min, // Fondamentale per il Masonry!
|
||||
children: [
|
||||
if (note.title != null && note.title!.isNotEmpty) ...[
|
||||
Text(
|
||||
note.title!,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (note.content != null && note.content!.isNotEmpty) ...[
|
||||
Text(
|
||||
note.content!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
height: 1.3,
|
||||
),
|
||||
// Limitiamo le righe in bacheca per non avere post-it lunghi 3 metri
|
||||
maxLines: 8,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// Footer con icone di stato (Pin, Condivisione, Allegati)
|
||||
_buildCardFooter(note),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardFooter(NoteModel note) {
|
||||
final hasStatusIcons =
|
||||
note.isPinned || note.isSharedAll || note.collaboratorIds.isNotEmpty;
|
||||
|
||||
if (!hasStatusIcons) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (note.isSharedAll || note.collaboratorIds.isNotEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: Icon(
|
||||
Icons.people_alt_outlined,
|
||||
size: 16,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: Icon(Icons.attachment, size: 16, color: Colors.black54),
|
||||
),
|
||||
if (note.isPinned)
|
||||
const Icon(Icons.push_pin, size: 16, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [],
|
||||
|
||||
@@ -483,7 +483,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
icon: Icons.design_services,
|
||||
themeColor: Colors.deepOrange,
|
||||
children: [
|
||||
Row(
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Privato (Domestico)'),
|
||||
@@ -514,6 +516,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
|
||||
@@ -8,14 +8,31 @@ 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 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: [
|
||||
@@ -23,6 +40,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text(
|
||||
"Protocolli e Numerazione",
|
||||
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -74,10 +92,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
),
|
||||
onChanged: (val) => context
|
||||
.read<DocumentSequenceCubit>()
|
||||
.updateLocalSequence(
|
||||
docType.name,
|
||||
prefix: val,
|
||||
),
|
||||
.updateLocalSequence(docType.name, prefix: val),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -103,9 +118,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
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
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -119,19 +132,20 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
Text(
|
||||
"Anteprima prossimo: ",
|
||||
style: TextStyle(
|
||||
color: Colors
|
||||
.grey
|
||||
.shade700, // Idem per la dark mode
|
||||
color:
|
||||
Colors.grey.shade700, // Idem per la dark mode
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
preview,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -152,7 +166,5 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
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)',
|
||||
|
||||
211
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
211
lib/features/tasks/blocs/task_form_cubit.dart
Normal 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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
lib/features/tasks/blocs/task_form_state.dart
Normal file
109
lib/features/tasks/blocs/task_form_state.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
30
lib/features/tasks/blocs/task_list_state.dart
Normal file
30
lib/features/tasks/blocs/task_list_state.dart
Normal 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];
|
||||
}
|
||||
211
lib/features/tasks/data/task_repository.dart
Normal file
211
lib/features/tasks/data/task_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
lib/features/tasks/models/task_model.dart
Normal file
152
lib/features/tasks/models/task_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
lib/features/tasks/models/task_status.dart
Normal file
40
lib/features/tasks/models/task_status.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
432
lib/features/tasks/ui/task_form_screen.dart
Normal file
432
lib/features/tasks/ui/task_form_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
272
lib/features/tasks/ui/task_list_screen.dart
Normal file
272
lib/features/tasks/ui/task_list_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class TicketRepository {
|
||||
.from(_tableName)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.customers} (*),
|
||||
customer:${Tables.customers}!ticket_customer_id_fkey (*),
|
||||
${Tables.shippingDocuments} (*, ${Tables.attachments} (*)),
|
||||
created_by:${Tables.staffMembers}!ticket_staff_id_fkey (*),
|
||||
assigned_to:${Tables.staffMembers}!ticket_assigned_to_id_fkey (*),
|
||||
@@ -89,7 +89,7 @@ class TicketRepository {
|
||||
.from(_tableName)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.customers} (*),
|
||||
customer:${Tables.customers}!ticket_customer_id_fkey (*),
|
||||
${Tables.shippingDocuments} (*, ${Tables.attachments} (*)),
|
||||
created_by:${Tables.staffMembers}!ticket_staff_id_fkey (*),
|
||||
assigned_to:${Tables.staffMembers}!ticket_assigned_to_id_fkey (*),
|
||||
@@ -200,7 +200,7 @@ class TicketRepository {
|
||||
.from(_tableName)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.customers} (*),
|
||||
customer:${Tables.customers}!ticket_customer_id_fkey (*),
|
||||
target_model:${Tables.models}!ticket_model_id_1_fkey (*),
|
||||
source_model:${Tables.models}!ticket_model_id_2_fkey (*),
|
||||
created_by:${Tables.staffMembers}!ticket_staff_id_fkey (*),
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,12 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// TRUCCO ANTI-RACE-CONDITION:
|
||||
// Se il ticket arriva già "pronto" (via extra), popoliamo i controller SUBITO,
|
||||
// senza aspettare il listener del BLoC che si perderebbe l'emissione sincrona.
|
||||
if (widget.existingTicket != null) {
|
||||
_syncTextControllers(widget.existingTicket!);
|
||||
}
|
||||
context.read<TicketFormCubit>().initForm(
|
||||
existingTicket: widget.existingTicket,
|
||||
id: widget.ticketId,
|
||||
@@ -135,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;
|
||||
@@ -778,6 +833,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<TicketType>(
|
||||
isExpanded: true,
|
||||
initialValue: ticket.ticketType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tipo Lavorazione',
|
||||
@@ -798,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
|
||||
@@ -809,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>(
|
||||
@@ -995,13 +1083,17 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
||||
child: Icon(icon, color: themeColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: themeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,76 +117,84 @@ 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,
|
||||
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),
|
||||
borderRadius: BorderRadius.circular(
|
||||
16,
|
||||
), // Qui possiamo giocare coi bordi
|
||||
),
|
||||
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),
|
||||
onPressed: () =>
|
||||
context.read<TicketListCubit>().clearSelection(),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface,
|
||||
onPressed: () => context
|
||||
.read<TicketListCubit>()
|
||||
.clearSelection(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${state.selectedTickets.length} selezionati',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,9 @@ import 'package:flux/core/utils/version_check_service.dart';
|
||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||
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';
|
||||
@@ -75,6 +78,7 @@ void main() async {
|
||||
BlocProvider<TicketListCubit>(create: (_) => TicketListCubit()),
|
||||
BlocProvider<OperationListCubit>(create: (_) => OperationListCubit()),
|
||||
BlocProvider<TrackingCubit>(create: (_) => TrackingCubit()),
|
||||
BlocProvider<NotesBloc>(create: (_) => NotesBloc()),
|
||||
],
|
||||
child: const FluxApp(),
|
||||
),
|
||||
@@ -132,6 +136,8 @@ Future<void> setupLocator() async {
|
||||
getIt.registerLazySingleton<TicketsShippingRepository>(
|
||||
() => TicketsShippingRepository(),
|
||||
);
|
||||
getIt.registerLazySingleton<NotesRepository>(() => NotesRepository());
|
||||
getIt.registerLazySingleton<TaskRepository>(() => TaskRepository());
|
||||
}
|
||||
|
||||
class FluxApp extends StatefulWidget {
|
||||
@@ -302,13 +308,15 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
kIsWeb
|
||||
? "Aggiornamento"
|
||||
: "Aggiornamento Obbligatorio",
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -347,13 +355,11 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
49
pubspec.lock
49
pubspec.lock
@@ -125,10 +125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -315,6 +315,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
flutter_staggered_grid_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_staggered_grid_view
|
||||
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -357,14 +365,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.1"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -401,10 +401,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -613,14 +613,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -633,10 +625,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -744,10 +736,11 @@ packages:
|
||||
pdfx:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdfx
|
||||
sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "packages/pdfx"
|
||||
ref: main
|
||||
resolved-ref: "900ad0799ebd12d6c87189275fcb6155ce8a9374"
|
||||
url: "https://github.com/ScerIO/packages.flutter.git"
|
||||
source: git
|
||||
version: "2.9.2"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
@@ -1110,10 +1103,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: flux
|
||||
description: "Gestione attività negozio di telefonia"
|
||||
publish_to: 'none'
|
||||
version: 1.0.1+2
|
||||
version: 1.0.18+18
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.3
|
||||
@@ -38,6 +38,14 @@ dependencies:
|
||||
font_awesome_flutter: ^11.0.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
package_info_plus: ^9.0.1
|
||||
flutter_staggered_grid_view: ^0.7.0
|
||||
|
||||
dependency_overrides:
|
||||
pdfx:
|
||||
git:
|
||||
url: https://github.com/ScerIO/packages.flutter.git
|
||||
ref: main
|
||||
path: packages/pdfx
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[Setup]
|
||||
; Informazioni di base dell'applicazione
|
||||
AppName=Flux
|
||||
AppVersion=1.0.0
|
||||
VersionInfoVersion=1.0.0
|
||||
AppVersion={#MyAppVersion}
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
AppPublisher=Catelli
|
||||
DefaultDirName={autopf}\Flux
|
||||
; DefaultGroupName=Programs
|
||||
|
||||
@@ -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.
|
||||
@@ -106,3 +107,6 @@ install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
CONFIGURATIONS Profile;Release
|
||||
COMPONENT Runtime)
|
||||
|
||||
|
||||
set(PDFIUM_VERSION "4638" CACHE STRING "")
|
||||
Reference in New Issue
Block a user