Initial commit
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class DashboardMetrics {
|
||||
DashboardMetrics({
|
||||
required this.newTicketsToday,
|
||||
required this.closedToday,
|
||||
required this.openTickets,
|
||||
required this.avgResponse,
|
||||
required this.avgTriage,
|
||||
required this.longestResponse,
|
||||
required this.tasksCreatedToday,
|
||||
required this.tasksCompletedToday,
|
||||
required this.openTasks,
|
||||
required this.staffRows,
|
||||
});
|
||||
|
||||
final int newTicketsToday;
|
||||
final int closedToday;
|
||||
final int openTickets;
|
||||
|
||||
final Duration? avgResponse;
|
||||
final Duration? avgTriage;
|
||||
final Duration? longestResponse;
|
||||
|
||||
final int tasksCreatedToday;
|
||||
final int tasksCompletedToday;
|
||||
final int openTasks;
|
||||
|
||||
final List<StaffRowMetrics> staffRows;
|
||||
}
|
||||
|
||||
class StaffRowMetrics {
|
||||
StaffRowMetrics({
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.status,
|
||||
required this.ticketsRespondedToday,
|
||||
required this.tasksClosedToday,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String name;
|
||||
final String status;
|
||||
final int ticketsRespondedToday;
|
||||
final int tasksClosedToday;
|
||||
}
|
||||
|
||||
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||
final ticketsAsync = ref.watch(ticketsProvider);
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
||||
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
||||
|
||||
final asyncValues = [
|
||||
ticketsAsync,
|
||||
tasksAsync,
|
||||
profilesAsync,
|
||||
assignmentsAsync,
|
||||
messagesAsync,
|
||||
];
|
||||
|
||||
if (asyncValues.any((value) => value.hasError)) {
|
||||
final errorValue = asyncValues.firstWhere((value) => value.hasError);
|
||||
final error = errorValue.error ?? 'Failed to load dashboard';
|
||||
final stack = errorValue.stackTrace ?? StackTrace.current;
|
||||
return AsyncError(error, stack);
|
||||
}
|
||||
|
||||
if (asyncValues.any((value) => value.isLoading)) {
|
||||
return const AsyncLoading();
|
||||
}
|
||||
|
||||
final tickets = ticketsAsync.valueOrNull ?? const <Ticket>[];
|
||||
final tasks = tasksAsync.valueOrNull ?? const <Task>[];
|
||||
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
|
||||
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
|
||||
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
|
||||
|
||||
final now = DateTime.now();
|
||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||
|
||||
final staffProfiles = profiles
|
||||
.where((profile) => profile.role == 'it_staff')
|
||||
.toList();
|
||||
final staffIds = profiles
|
||||
.where(
|
||||
(profile) =>
|
||||
profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff',
|
||||
)
|
||||
.map((profile) => profile.id)
|
||||
.toSet();
|
||||
|
||||
bool isToday(DateTime value) => !value.isBefore(startOfDay);
|
||||
|
||||
final firstStaffMessageByTicket = <String, DateTime>{};
|
||||
final lastStaffMessageByUser = <String, DateTime>{};
|
||||
final respondedTicketsByUser = <String, Set<String>>{};
|
||||
for (final message in messages) {
|
||||
final ticketId = message.ticketId;
|
||||
final senderId = message.senderId;
|
||||
if (ticketId != null && senderId != null && staffIds.contains(senderId)) {
|
||||
final current = firstStaffMessageByTicket[ticketId];
|
||||
if (current == null || message.createdAt.isBefore(current)) {
|
||||
firstStaffMessageByTicket[ticketId] = message.createdAt;
|
||||
}
|
||||
final last = lastStaffMessageByUser[senderId];
|
||||
if (last == null || message.createdAt.isAfter(last)) {
|
||||
lastStaffMessageByUser[senderId] = message.createdAt;
|
||||
}
|
||||
if (isToday(message.createdAt)) {
|
||||
respondedTicketsByUser
|
||||
.putIfAbsent(senderId, () => <String>{})
|
||||
.add(ticketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? respondedAtForTicket(Ticket ticket) {
|
||||
final staffMessageAt = firstStaffMessageByTicket[ticket.id];
|
||||
if (staffMessageAt != null) {
|
||||
return staffMessageAt;
|
||||
}
|
||||
if (ticket.promotedAt != null) {
|
||||
return ticket.promotedAt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Duration? responseDuration(Ticket ticket) {
|
||||
final respondedAt = respondedAtForTicket(ticket);
|
||||
if (respondedAt == null) {
|
||||
return null;
|
||||
}
|
||||
final duration = respondedAt.difference(ticket.createdAt);
|
||||
return duration.isNegative ? Duration.zero : duration;
|
||||
}
|
||||
|
||||
Duration? triageDuration(Ticket ticket) {
|
||||
final respondedAt = respondedAtForTicket(ticket);
|
||||
if (respondedAt == null) {
|
||||
return null;
|
||||
}
|
||||
final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt);
|
||||
if (triageEnd == null) {
|
||||
return null;
|
||||
}
|
||||
final duration = triageEnd.difference(respondedAt);
|
||||
return duration.isNegative ? Duration.zero : duration;
|
||||
}
|
||||
|
||||
final ticketsToday = tickets.where((ticket) => isToday(ticket.createdAt));
|
||||
final closedToday = tickets.where(
|
||||
(ticket) => ticket.closedAt != null && isToday(ticket.closedAt!),
|
||||
);
|
||||
final openTickets = tickets.where((ticket) => ticket.status != 'closed');
|
||||
|
||||
final responseDurationsToday = ticketsToday
|
||||
.map(responseDuration)
|
||||
.whereType<Duration>()
|
||||
.toList();
|
||||
final triageDurationsToday = ticketsToday
|
||||
.map(triageDuration)
|
||||
.whereType<Duration>()
|
||||
.toList();
|
||||
|
||||
final avgResponse = _averageDuration(responseDurationsToday);
|
||||
final avgTriage = _averageDuration(triageDurationsToday);
|
||||
final longestResponse = responseDurationsToday.isEmpty
|
||||
? null
|
||||
: responseDurationsToday.reduce(
|
||||
(a, b) => a.inSeconds >= b.inSeconds ? a : b,
|
||||
);
|
||||
|
||||
final tasksCreatedToday = tasks.where((task) => isToday(task.createdAt));
|
||||
final tasksCompletedToday = tasks.where(
|
||||
(task) => task.completedAt != null && isToday(task.completedAt!),
|
||||
);
|
||||
final openTasks = tasks.where((task) => task.status != 'completed');
|
||||
|
||||
final taskById = {for (final task in tasks) task.id: task};
|
||||
final staffOnTask = <String>{};
|
||||
for (final assignment in assignments) {
|
||||
final task = taskById[assignment.taskId];
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if (task.status == 'in_progress') {
|
||||
staffOnTask.add(assignment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
final tasksClosedByUser = <String, Set<String>>{};
|
||||
for (final assignment in assignments) {
|
||||
final task = taskById[assignment.taskId];
|
||||
if (task == null || task.completedAt == null) {
|
||||
continue;
|
||||
}
|
||||
if (!isToday(task.completedAt!)) {
|
||||
continue;
|
||||
}
|
||||
tasksClosedByUser
|
||||
.putIfAbsent(assignment.userId, () => <String>{})
|
||||
.add(task.id);
|
||||
}
|
||||
|
||||
const triageWindow = Duration(minutes: 1);
|
||||
final triageCutoff = now.subtract(triageWindow);
|
||||
|
||||
final staffRows = staffProfiles.map((staff) {
|
||||
final lastMessage = lastStaffMessageByUser[staff.id];
|
||||
final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0;
|
||||
final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0;
|
||||
final onTask = staffOnTask.contains(staff.id);
|
||||
final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
|
||||
final status = onTask
|
||||
? 'On task'
|
||||
: inTriage
|
||||
? 'In triage'
|
||||
: 'Vacant';
|
||||
|
||||
return StaffRowMetrics(
|
||||
userId: staff.id,
|
||||
name: staff.fullName.isNotEmpty ? staff.fullName : staff.id,
|
||||
status: status,
|
||||
ticketsRespondedToday: ticketsResponded,
|
||||
tasksClosedToday: tasksClosed,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return AsyncData(
|
||||
DashboardMetrics(
|
||||
newTicketsToday: ticketsToday.length,
|
||||
closedToday: closedToday.length,
|
||||
openTickets: openTickets.length,
|
||||
avgResponse: avgResponse,
|
||||
avgTriage: avgTriage,
|
||||
longestResponse: longestResponse,
|
||||
tasksCreatedToday: tasksCreatedToday.length,
|
||||
tasksCompletedToday: tasksCompletedToday.length,
|
||||
openTasks: openTasks.length,
|
||||
staffRows: staffRows,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
class DashboardScreen extends StatelessWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ResponsiveBody(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 980;
|
||||
final metricsColumn = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Dashboard',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const _DashboardStatusBanner(),
|
||||
_sectionTitle(context, 'Core Daily KPIs'),
|
||||
_cardGrid(context, [
|
||||
_MetricCard(
|
||||
title: 'New tickets today',
|
||||
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Closed today',
|
||||
valueBuilder: (metrics) => metrics.closedToday.toString(),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Open tickets',
|
||||
valueBuilder: (metrics) => metrics.openTickets.toString(),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(context, 'TAT / Response'),
|
||||
_cardGrid(context, [
|
||||
_MetricCard(
|
||||
title: 'Avg response',
|
||||
valueBuilder: (metrics) =>
|
||||
_formatDuration(metrics.avgResponse),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Avg triage',
|
||||
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Longest response',
|
||||
valueBuilder: (metrics) =>
|
||||
_formatDuration(metrics.longestResponse),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(context, 'Task Flow'),
|
||||
_cardGrid(context, [
|
||||
_MetricCard(
|
||||
title: 'Tasks created',
|
||||
valueBuilder: (metrics) =>
|
||||
metrics.tasksCreatedToday.toString(),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Tasks completed',
|
||||
valueBuilder: (metrics) =>
|
||||
metrics.tasksCompletedToday.toString(),
|
||||
),
|
||||
_MetricCard(
|
||||
title: 'Open tasks',
|
||||
valueBuilder: (metrics) => metrics.openTasks.toString(),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
final staffColumn = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle(context, 'IT Staff Pulse'),
|
||||
const _StaffTable(),
|
||||
],
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.6,
|
||||
),
|
||||
child: metricsColumn,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.35,
|
||||
),
|
||||
child: staffColumn,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
metricsColumn,
|
||||
const SizedBox(height: 12),
|
||||
staffColumn,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(BuildContext context, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cardGrid(BuildContext context, List<Widget> cards) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final columns = width >= 900
|
||||
? 3
|
||||
: width >= 620
|
||||
? 2
|
||||
: 1;
|
||||
final spacing = 12.0;
|
||||
final cardWidth = (width - (columns - 1) * spacing) / columns;
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: cards
|
||||
.map((card) => SizedBox(width: cardWidth, child: card))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardStatusBanner extends ConsumerWidget {
|
||||
const _DashboardStatusBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||
return metricsAsync.when(
|
||||
data: (_) => const SizedBox.shrink(),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
error: (error, _) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Dashboard data error: $error',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends ConsumerWidget {
|
||||
const _MetricCard({required this.title, required this.valueBuilder});
|
||||
|
||||
final String title;
|
||||
final String Function(DashboardMetrics metrics) valueBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||
final value = metricsAsync.when(
|
||||
data: (metrics) => valueBuilder(metrics),
|
||||
loading: () => '—',
|
||||
error: (error, _) => 'Error',
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffTable extends StatelessWidget {
|
||||
const _StaffTable();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
_StaffTableHeader(),
|
||||
SizedBox(height: 8),
|
||||
_StaffTableBody(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffTableHeader extends StatelessWidget {
|
||||
const _StaffTableHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: Text('IT Staff', style: style)),
|
||||
Expanded(flex: 2, child: Text('Status', style: style)),
|
||||
Expanded(flex: 2, child: Text('Tickets', style: style)),
|
||||
Expanded(flex: 2, child: Text('Tasks', style: style)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffTableBody extends ConsumerWidget {
|
||||
const _StaffTableBody();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||
return metricsAsync.when(
|
||||
data: (metrics) {
|
||||
if (metrics.staffRows.isEmpty) {
|
||||
return Text(
|
||||
'No IT staff available.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: metrics.staffRows
|
||||
.map((row) => _StaffRow(row: row))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
loading: () => const Text('Loading staff...'),
|
||||
error: (error, _) => Text('Failed to load staff: $error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffRow extends StatelessWidget {
|
||||
const _StaffRow({required this.row});
|
||||
|
||||
final StaffRowMetrics row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final valueStyle = Theme.of(context).textTheme.bodySmall;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
|
||||
Expanded(flex: 2, child: Text(row.status, style: valueStyle)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
row.ticketsRespondedToday.toString(),
|
||||
style: valueStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Duration? _averageDuration(List<Duration> durations) {
|
||||
if (durations.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final totalSeconds = durations
|
||||
.map((duration) => duration.inSeconds)
|
||||
.reduce((a, b) => a + b);
|
||||
return Duration(seconds: (totalSeconds / durations.length).round());
|
||||
}
|
||||
|
||||
DateTime? _earliestDate(DateTime? first, DateTime? second) {
|
||||
if (first == null) return second;
|
||||
if (second == null) return first;
|
||||
return first.isBefore(second) ? first : second;
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) {
|
||||
return 'Pending';
|
||||
}
|
||||
if (duration.inSeconds < 60) {
|
||||
return 'Less than a minute';
|
||||
}
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
Reference in New Issue
Block a user