From a39e33bc6b7a1c789290206a2d62da86967d253a Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 18 Mar 2026 22:04:15 +0800 Subject: [PATCH] Added Team Activity Dashboard --- lib/screens/dashboard/dashboard_screen.dart | 368 +++++++++++++++++++- 1 file changed, 366 insertions(+), 2 deletions(-) diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 6178ab14..824bb9a2 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -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 { 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 []; + final members = teamMembersAsync.valueOrNull ?? const []; + 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 = >{}; + 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 = >[]; + 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 = >[]; + 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.generate( + tickCount, + (i) => ((capValue * (tickCount - 1 - i) / (tickCount - 1))) + .round(), + ); + final ticks = []; + 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});