tasks
This commit is contained in:
@@ -46,8 +46,10 @@ import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
|||||||
import 'package:flux/features/settings/settings_screen.dart';
|
import 'package:flux/features/settings/settings_screen.dart';
|
||||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||||
import 'package:flux/features/tasks/blocs/task_form_cubit.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/models/task_model.dart';
|
||||||
import 'package:flux/features/tasks/ui/task_form_screen.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/blocs/ticket_form_cubit.dart';
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
||||||
@@ -238,11 +240,34 @@ class AppRouter {
|
|||||||
name: Routes.notes,
|
name: Routes.notes,
|
||||||
builder: (context, state) => const NotesListScreen(),
|
builder: (context, state) => const NotesListScreen(),
|
||||||
),
|
),
|
||||||
/* GoRoute(
|
GoRoute(
|
||||||
path: '/tasks',
|
path: '/tasks',
|
||||||
name: Routes.tasks,
|
name: Routes.tasks,
|
||||||
builder: (context, state) => const TaskListScreen(),
|
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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,50 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/features/tasks/data/task_repository.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_model.dart';
|
||||||
import 'package:flux/features/tasks/models/task_status.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
part 'task_list_state.dart';
|
part 'task_list_state.dart';
|
||||||
|
|
||||||
class TaskListCubit extends Cubit<TaskListState> {
|
class TaskListCubit extends Cubit<TaskListState> {
|
||||||
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
|
||||||
RealtimeChannel? _taskChannel;
|
|
||||||
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
|
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
|
||||||
|
final String currentCompanyId;
|
||||||
|
final String? currentStoreId;
|
||||||
|
|
||||||
TaskListCubit() : super(TaskListState(status: TaskListStatus.initial));
|
// Il nostro abbonamento allo stream del repository
|
||||||
|
StreamSubscription<void>? _taskSubscription;
|
||||||
|
|
||||||
// --- AVVIA L'ASCOLTO IN TEMPO REALE ---
|
TaskListCubit({required this.currentCompanyId, this.currentStoreId})
|
||||||
void startListening({required String companyId, String? storeId}) {
|
: super(const TaskListState()) {
|
||||||
emit(state.copyWith(status: TaskListStatus.loading));
|
_initRealtime();
|
||||||
|
|
||||||
// Facciamo subito il caricamento manuale, chiedendo SOLO quelli attivi
|
|
||||||
_loadTasks(companyId: companyId, storeId: storeId);
|
|
||||||
|
|
||||||
_taskChannel?.unsubscribe();
|
|
||||||
|
|
||||||
_taskChannel = _supabase
|
|
||||||
.channel('public:tasks_company_$companyId')
|
|
||||||
.onPostgresChanges(
|
|
||||||
event: PostgresChangeEvent.all,
|
|
||||||
schema: 'public',
|
|
||||||
table: 'tasks',
|
|
||||||
filter: PostgresChangeFilter(
|
|
||||||
type: PostgresChangeFilterType.eq,
|
|
||||||
column: 'company_id',
|
|
||||||
value: companyId,
|
|
||||||
),
|
|
||||||
callback: (payload) {
|
|
||||||
// Ricarica la lista applicando sempre i filtri di stato
|
|
||||||
_loadTasks(companyId: companyId, storeId: storeId);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
_taskChannel?.subscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER DI CARICAMENTO ---
|
void _initRealtime() {
|
||||||
Future<void> _loadTasks({required String companyId, String? storeId}) async {
|
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 {
|
try {
|
||||||
final tasks = await _repository.getTasks(
|
final tasks = await _repository.getTasks(
|
||||||
companyId: companyId,
|
companyId: currentCompanyId,
|
||||||
storeId: storeId,
|
storeId: currentStoreId,
|
||||||
// CHICCA: Passiamo solo gli stati attivi!
|
|
||||||
statuses: [TaskStatus.open, TaskStatus.inProgress],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
@@ -73,20 +64,10 @@ class TaskListCubit extends Cubit<TaskListState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- OPERAZIONI MANUALI ---
|
|
||||||
// (Le lasciamo gestire al repository o le metti qui se preferisci,
|
|
||||||
// tanto il risultato lo vedrai magicamente aggiornato dallo stream sopra!)
|
|
||||||
|
|
||||||
/*
|
|
||||||
Future<void> markAsCompleted(String taskId) async { ... }
|
|
||||||
Future<void> deleteTask(String taskId) async { ... }
|
|
||||||
*/
|
|
||||||
|
|
||||||
// --- PULIZIA FONDAMENTALE ---
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
// Chiudiamo il rubinetto quando usciamo dalla pagina per non intasarci la memoria!
|
// Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto!
|
||||||
_taskChannel?.unsubscribe();
|
_taskSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ class TaskListState extends Equatable {
|
|||||||
return TaskListState(
|
return TaskListState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
tasks: tasks ?? this.tasks,
|
tasks: tasks ?? this.tasks,
|
||||||
// Se lo status è success o loading, puliamo eventuali errori precedenti
|
errorMessage: errorMessage,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/tasks/models/task_status.dart';
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
@@ -10,6 +12,42 @@ class TaskRepository {
|
|||||||
TaskRepository({SupabaseClient? supabase})
|
TaskRepository({SupabaseClient? supabase})
|
||||||
: _supabase = supabase ?? Supabase.instance.client;
|
: _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 ---
|
// --- RECUPERO DEI TASK FILTRATI ---
|
||||||
Future<List<TaskModel>> getTasks({
|
Future<List<TaskModel>> getTasks({
|
||||||
required String companyId,
|
required String companyId,
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user