diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 47e209a..7103961 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -30,6 +30,9 @@ import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/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'; @@ -200,6 +203,11 @@ class AppRouter { name: Routes.tickets, builder: (context, state) => const TicketListScreen(), ), + GoRoute( + path: '/notes', + name: Routes.notes, + builder: (context, state) => const NotesListScreen(), + ), ], ), @@ -436,6 +444,28 @@ class AppRouter { ); }, ), + GoRoute( + path: '/notes/edit/:id', + name: Routes.noteForm, + builder: (context, state) { + final id = state.pathParameters['id']!; + final NoteModel note = state.extra as NoteModel; + + // Creiamo il BLoC "al volo" solo per questa schermata + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AttachmentsBloc( + parentId: id, + parentType: AttachmentParentType.note, + ), + ), + ], + + child: NoteFormScreen(note: note), + ); + }, + ), ], ); } diff --git a/lib/core/routes/routes.dart b/lib/core/routes/routes.dart index a34609f..24818a9 100644 --- a/lib/core/routes/routes.dart +++ b/lib/core/routes/routes.dart @@ -22,4 +22,6 @@ 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'; } diff --git a/lib/core/utils/debouncer.dart b/lib/core/utils/debouncer.dart new file mode 100644 index 0000000..c1bb802 --- /dev/null +++ b/lib/core/utils/debouncer.dart @@ -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(); + } +} diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 1c3e277..f382203 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -10,6 +10,9 @@ import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_ 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:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class HomeScreen extends StatelessWidget { @@ -62,6 +65,8 @@ class HomeScreen extends StatelessWidget { childAspectRatio: 1.3, ), delegate: SliverChildListDelegate([ + LatestStoreOperationsCard(), + LatestStoreTicketsCard(), _buildDashboardWidget( title: context.l10n.homeExpiringContracts, icon: Icons.assignment_late_outlined, @@ -73,6 +78,7 @@ class HomeScreen extends StatelessWidget { icon: Icons.sticky_note_2_outlined, color: Colors.yellow.shade700, context: context, + onTap: () => context.pushNamed(Routes.notes), ), _buildDashboardWidget( title: context.l10n.homeMyTasks, @@ -80,8 +86,6 @@ class HomeScreen extends StatelessWidget { color: Colors.green, context: context, ), - LatestStoreOperationsCard(), - LatestStoreTicketsCard(), ]), ), ), @@ -211,8 +215,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().state.company!.id!; + final currentStaffId = context + .read() + .state + .currentStaffMember! + .id!; + final emptyNote = NoteModel.empty( + createdBy: currentStaffId, + companyId: companyId, + ); + final noteId = await GetIt.I.get().saveNote( + emptyNote, + ); + if (!context.mounted) return; + context.pushNamed( + Routes.noteForm, + pathParameters: {'id': noteId}, + extra: emptyNote.copyWith(id: noteId), + ); }, ), const SizedBox(width: 12), diff --git a/lib/features/master_data/staff/data/staff_repository.dart b/lib/features/master_data/staff/data/staff_repository.dart index 9c89c9d..dd119a8 100644 --- a/lib/features/master_data/staff/data/staff_repository.dart +++ b/lib/features/master_data/staff/data/staff_repository.dart @@ -20,6 +20,19 @@ class StaffRepository { return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList(); } + Future getStaffMemberById(String staffId) async { + try { + final response = await _supabase + .from(Tables.staffMembers) + .select() + .eq('id', staffId) + .single(); + return StaffMemberModel.fromMap(response); + } on Exception catch (e) { + throw ('Errore nel recupero del membro staff con ID $staffId: $e'); + } + } + Future saveStaffMember(StaffMemberModel member) async { final response = await _supabase .from(Tables.staffMembers) diff --git a/lib/features/notes/blocs/notes_bloc.dart b/lib/features/notes/blocs/notes_bloc.dart new file mode 100644 index 0000000..c6112b7 --- /dev/null +++ b/lib/features/notes/blocs/notes_bloc.dart @@ -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 { + final NotesRepository _repository = GetIt.I.get(); + final String _companyId = GetIt.I.get().state.company!.id!; + final String _currentStaffId = GetIt.I + .get() + .state + .currentStaffMember! + .id!; + + NotesBloc() : super(const NotesState()) { + on(_onSubscribeToNotesRequested); + on(_onNoteSavedRequested); + on(_onNoteDeletedRequested); + + // Facciamo partire l'ascolto in tempo reale al boot del BLoC + add(SubscribeToNotesRequested()); + } + + Future _onSubscribeToNotesRequested( + SubscribeToNotesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: NotesStatus.loading)); + + // Usiamo l'emit.forEach sullo stream pulito del repository + await emit.forEach>( + _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 _onNoteSavedRequested( + NoteSavedRequested event, + Emitter 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 _onNoteDeletedRequested( + NoteDeletedRequested event, + Emitter 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()), + ); + } + } +} diff --git a/lib/features/notes/blocs/notes_event.dart b/lib/features/notes/blocs/notes_event.dart new file mode 100644 index 0000000..e4cd733 --- /dev/null +++ b/lib/features/notes/blocs/notes_event.dart @@ -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); +} diff --git a/lib/features/notes/blocs/notes_state.dart b/lib/features/notes/blocs/notes_state.dart new file mode 100644 index 0000000..2ab983f --- /dev/null +++ b/lib/features/notes/blocs/notes_state.dart @@ -0,0 +1,39 @@ +part of 'notes_bloc.dart'; + +enum NotesStatus { initial, loading, success, failure } + +class NotesState { + final NotesStatus status; + final List notes; + final String? errorMessage; + + const NotesState({ + this.status = NotesStatus.initial, + this.notes = const [], + this.errorMessage, + }); + + NotesState copyWith({ + NotesStatus? status, + List? 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; +} diff --git a/lib/features/notes/data/notes_repository.dart b/lib/features/notes/data/notes_repository.dart new file mode 100644 index 0000000..87bab1a --- /dev/null +++ b/lib/features/notes/data/notes_repository.dart @@ -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(); + + String get _companyId => GetIt.I.get().state.company!.id!; + String get _currentStaffId => + GetIt.I.get().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> 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)) + .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> _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)) + .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> 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 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 deleteNote(String noteId) async { + await _supabase.from(Tables.notes).delete().eq('id', noteId); + } +} diff --git a/lib/features/notes/models/note_model.dart b/lib/features/notes/models/note_model.dart index 887f7b4..36e6577 100644 --- a/lib/features/notes/models/note_model.dart +++ b/lib/features/notes/models/note_model.dart @@ -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 collaboratorIds; // Campi di utilità per la UI e le relazioni - final List collaboratorIds; - final List collaborators; + //final List 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? collaboratorIds, - List? 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 toMap() { @@ -91,27 +91,6 @@ class NoteModel extends Equatable { } factory NoteModel.fromMap(Map map) { - // Estraiamo gli ID dei collaboratori se presenti dalla join nativa di Supabase - List collIds = []; - List 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, - ), - ); - } - } - } - 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 pulita + collaboratorIds: map['collaborator_ids'] != null + ? List.from(map['collaborator_ids'] as List) + : [], ); } @@ -145,6 +126,6 @@ class NoteModel extends Equatable { isSharedAll, companyId, collaboratorIds, - collaborators, + //collaborators, ]; } diff --git a/lib/features/notes/ui/dashboard_notes_widget.dart b/lib/features/notes/ui/dashboard_notes_widget.dart new file mode 100644 index 0000000..084ec36 --- /dev/null +++ b/lib/features/notes/ui/dashboard_notes_widget.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Intestazione del riquadro + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Le mie Note', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + TextButton( + onPressed: () { + // Vai alla bacheca completa + context.push('/notes'); + }, + child: const Text('Vedi tutte'), + ), + ], + ), + const SizedBox(height: 12), + + // Il corpo del widget collegato al Bloc + BlocBuilder( + 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.push('/notes/edit/${note.id}'); + }, + 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'), + ), + ], + ), + ); + } +} diff --git a/lib/features/notes/ui/notes_form_screen.dart b/lib/features/notes/ui/notes_form_screen.dart new file mode 100644 index 0000000..e09fb7d --- /dev/null +++ b/lib/features/notes/ui/notes_form_screen.dart @@ -0,0 +1,449 @@ +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/core/widgets/shared_forms/attachments_section.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.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 createState() => _NoteFormScreenState(); +} + +class _NoteFormScreenState extends State { + 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 _selectedStaffIds; + + // Inizializziamo il Debouncer a 500 millisecondi + final _debouncer = Debouncer(milliseconds: 500); + + final List _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(); + _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().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 _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().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) --- + Row( + children: [ + // Tavolozza Colori + Expanded( + child: SizedBox( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _noteColors.length, + itemBuilder: (context, index) { + final colorHex = _noteColors[index]; + final isSelected = _selectedColor == colorHex; + final c = Color( + int.parse( + 'FF${colorHex.replaceAll('#', '')}', + radix: 16, + ), + ); + + return GestureDetector( + onTap: () { + setState(() => _selectedColor = colorHex); + _triggerAutoSave(); + }, + child: Container( + margin: const EdgeInsets.only(right: 12), + width: 40, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Colors.black54 + : Colors.black12, + width: isSelected ? 3 : 1, + ), + ), + child: isSelected + ? const Icon( + Icons.check, + color: Colors.black54, + size: 20, + ) + : null, + ), + ); + }, + ), + ), + ), + const SizedBox(width: 16), + + // Azioni spostate dentro la nota! + 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, + ), + ], + ), + 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, + activeColor: 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!, + // ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/notes/ui/notes_list_screen.dart b/lib/features/notes/ui/notes_list_screen.dart new file mode 100644 index 0000000..d483dca --- /dev/null +++ b/lib/features/notes/ui/notes_list_screen.dart @@ -0,0 +1,245 @@ +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 _createNewNoteAndNavigate(BuildContext context) async { + final sessionState = context.read().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().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.shade50, // 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( + 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.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 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.push('/notes/edit', 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), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5f95c4f..50581ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ 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/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 +77,7 @@ void main() async { BlocProvider(create: (_) => TicketListCubit()), BlocProvider(create: (_) => OperationListCubit()), BlocProvider(create: (_) => TrackingCubit()), + BlocProvider(create: (_) => NotesBloc()), ], child: const FluxApp(), ), @@ -132,6 +135,7 @@ Future setupLocator() async { getIt.registerLazySingleton( () => TicketsShippingRepository(), ); + getIt.registerLazySingleton(() => NotesRepository()); } class FluxApp extends StatefulWidget { diff --git a/pubspec.lock b/pubspec.lock index 05e45e7..066346d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 16a53c9..68dc727 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ 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 dev_dependencies: flutter_test: sdk: flutter