Added Team Activity Dashboard

This commit is contained in:
Marc Rejohn Castillano 2026-03-18 22:04:15 +08:00
parent 4b63b55812
commit a39e33bc6b

View File

@ -29,6 +29,7 @@ import '../../providers/it_service_request_provider.dart';
import '../../providers/teams_provider.dart';
import '../../models/team.dart';
import '../../models/team_member.dart';
import 'dart:math' as math;
import '../../widgets/responsive_body.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/profile_avatar.dart';
@ -672,6 +673,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'),
const _StaffTable(),
const SizedBox(height: 12),
_sectionTitle(context, 'Team Activity'),
const _StaffActivityChart(),
const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'),
_cardGrid(context, [
@ -896,8 +900,6 @@ class _MetricCard extends ConsumerWidget {
),
),
);
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
@ -1178,6 +1180,368 @@ class _StaffRow extends StatelessWidget {
}
}
class _StaffActivityChart extends ConsumerWidget {
const _StaffActivityChart();
static const _avatarDiameter = 30.0;
static const _barWidth = 56.0;
static const _barSpacing = 14.0;
static const _chartHeight = 260.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final metricsAsync = ref.watch(dashboardMetricsProvider);
final teamsAsync = ref.watch(teamsProvider);
final teamMembersAsync = ref.watch(teamMembersProvider);
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
),
child: metricsAsync.when(
data: (metrics) {
final staffRows = metrics.staffRows;
if (staffRows.isEmpty) {
return Text(
'No staff activity data available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
final teams = teamsAsync.valueOrNull ?? const <Team>[];
final members = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
final teamById = {for (final t in teams) t.id: t};
final teamIdByUser = {for (final m in members) m.userId: m.teamId};
// Group rows by team name in the order they appear.
final teamGroups = <String, List<StaffRowMetrics>>{};
for (final row in staffRows) {
final teamId = teamIdByUser[row.userId];
final teamName = teamId != null ? teamById[teamId]?.name : null;
final label = teamName ?? 'Unassigned';
teamGroups.putIfAbsent(label, () => []).add(row);
}
final teamColorByName = {for (final t in teams) t.name: t.color};
final flattened = <_UserBarData>[];
final teamLegend = <MapEntry<String, int>>[];
for (final entry in teamGroups.entries) {
final teamName = entry.key;
final rows = entry.value;
for (final row in rows) {
flattened.add(_UserBarData(teamName: teamName, row: row));
}
teamLegend.add(MapEntry(teamName, rows.length));
}
Color? parseHexColor(String? hex) {
if (hex == null || hex.isEmpty) return null;
try {
return Color(int.parse(hex, radix: 16) | 0xFF000000);
} catch (_) {
return null;
}
}
final totals = flattened
.map(
(d) =>
d.row.ticketsRespondedToday +
d.row.tasksClosedToday +
d.row.eventsHandledToday,
)
.toList();
// Use the actual maximum total as the display cap so bar heights
// are strictly proportional to their numeric values.
final capValue = totals.isEmpty
? 1
: totals.reduce((a, b) => math.max(a, b));
final totalHeight = _chartHeight - _avatarDiameter - 24;
final barHeightFactor = capValue > 0
? totalHeight / math.max(capValue, 1)
: 0.0;
Widget buildBar(_UserBarData data) {
final tickets = data.row.ticketsRespondedToday;
final tasks = data.row.tasksClosedToday;
final events = data.row.eventsHandledToday;
final total = tickets + tasks + events;
// Linear mapping to the maximum value so heights are proportional
// to actual totals.
final barHeight = total * barHeightFactor;
final teamHex = teamColorByName[data.teamName];
final teamColor =
parseHexColor(teamHex) ?? Theme.of(context).colorScheme.primary;
final segments = <MapEntry<int, Color>>[];
if (tickets > 0) {
segments.add(
MapEntry(tickets, teamColor.withAlpha((0.9 * 255).round())),
);
}
if (tasks > 0) {
segments.add(
MapEntry(tasks, teamColor.withAlpha((0.7 * 255).round())),
);
}
if (events > 0) {
segments.add(
MapEntry(events, teamColor.withAlpha((0.5 * 255).round())),
);
}
// Ensure bars never overflow the chart vertical constraints by
// clamping the computed bar height and forcing the bar widget to
// occupy the same overall chart height. This prevents the
// RenderFlex overflow seen when a bar's intrinsic height exceeded
// the available space.
final maxBarContainerHeight = math.max(
0.0,
_chartHeight - (_avatarDiameter * 0.5) - 12,
);
final clampedBarHeight = math.min(barHeight, maxBarContainerHeight);
return SizedBox(
height: _chartHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Stack(
alignment: Alignment.topCenter,
children: [
// Bar container: fixed (clamped) height so inner
// Expanded segments layout into a finite box.
Container(
width: _barWidth,
height: clampedBarHeight,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final segment in segments)
Expanded(
flex: segment.key,
child: Container(
width: double.infinity,
color: segment.value,
),
),
],
),
),
// Avatar overlapped on the bar (top center). The
// avatar is allowed to overflow slightly above the
// bar that's intentional for the visual style.
Positioned(
top: 0,
child: Container(
width: _avatarDiameter,
height: _avatarDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow
.withAlpha((0.25 * 255).round()),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Tooltip(
message: data.row.name,
child: ProfileAvatar(
fullName: data.row.name,
avatarUrl: data.row.avatarUrl,
radius: _avatarDiameter / 2,
),
),
),
),
if (total > 0)
Positioned(
bottom: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withAlpha(
(0.35 * 255).round(),
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
total.toString(),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
// (Overflow indicator left in place for future cap logic.)
],
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final barCount = flattened.length;
final requiredWidth =
barCount * (_barWidth + _barSpacing) - _barSpacing;
final showScroll = requiredWidth > constraints.maxWidth;
final chartRow = Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < flattened.length; i++) ...[
buildBar(flattened[i]),
if (i < flattened.length - 1)
const SizedBox(width: _barSpacing),
],
],
);
// Build a per-team legend so the X-axis has only one label per team.
Widget buildTeamLegend() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var entry in teamLegend) ...[
SizedBox(
width:
(entry.value * _barWidth) +
((entry.value - 1) * _barSpacing),
child: Text(
entry.key,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(width: _barSpacing),
],
],
);
}
// Left axis width reserved for dynamic numeric labels.
const leftAxisWidth = 56.0;
// Build numeric ticks for the Y-axis (capValue .. 0) and remove
// consecutive duplicates that occur when capValue is small.
final tickCount = 4;
final ticksRaw = List<int>.generate(
tickCount,
(i) => ((capValue * (tickCount - 1 - i) / (tickCount - 1)))
.round(),
);
final ticks = <int>[];
for (final t in ticksRaw) {
if (ticks.isEmpty || ticks.last != t) ticks.add(t);
}
if (ticks.isEmpty || ticks.last != 0) ticks.add(0);
return SizedBox(
height: _chartHeight + 40,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: leftAxisWidth,
child: Column(
children: [
// Small spacer for avatar overlap area
SizedBox(
height: (_avatarDiameter / 2).roundToDouble(),
),
// Ticks aligned within the remaining chart area
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final t in ticks)
Text(
t.toString(),
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: showScroll
? const BouncingScrollPhysics()
: const NeverScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: _chartHeight, child: chartRow),
const SizedBox(height: 12),
buildTeamLegend(),
],
),
),
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Text('Failed to load activity: $error'),
),
);
}
}
class _UserBarData {
_UserBarData({required this.teamName, required this.row});
final String teamName;
final StaffRowMetrics row;
}
/// Status pill with attendance-specific coloring for the IT Staff Pulse table.
class _PulseStatusPill extends StatelessWidget {
const _PulseStatusPill({required this.label});