diff --git a/lib/features/front_hall/data/models/system_stats_model.dart b/lib/features/front_hall/data/models/system_stats_model.dart new file mode 100644 index 0000000..46cf8ac --- /dev/null +++ b/lib/features/front_hall/data/models/system_stats_model.dart @@ -0,0 +1,91 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'system_stats_model.freezed.dart'; +part 'system_stats_model.g.dart'; + +/// System statistics response from Core API. +@freezed +sealed class SystemStats with _$SystemStats { + const factory SystemStats({ + required CpuStats cpu, + required MemoryStats memory, + required List disks, + required NetworkStats network, + required GpuStats gpu, + required String hostname, + @JsonKey(name: 'queried_at') required DateTime queriedAt, + }) = _SystemStats; + + factory SystemStats.fromJson(Map json) => + _$SystemStatsFromJson(json); +} + +@freezed +sealed class CpuStats with _$CpuStats { + const factory CpuStats({ + @JsonKey(name: 'usage_percent') required double usagePercent, + required int cores, + @JsonKey(name: 'load_1m') double? load1m, + @JsonKey(name: 'load_5m') double? load5m, + @JsonKey(name: 'load_15m') double? load15m, + }) = _CpuStats; + + factory CpuStats.fromJson(Map json) => + _$CpuStatsFromJson(json); +} + +@freezed +sealed class MemoryStats with _$MemoryStats { + const factory MemoryStats({ + @JsonKey(name: 'usage_percent') required double usagePercent, + @JsonKey(name: 'total_bytes') required int totalBytes, + @JsonKey(name: 'used_bytes') required int usedBytes, + @JsonKey(name: 'available_bytes') required int availableBytes, + }) = _MemoryStats; + + factory MemoryStats.fromJson(Map json) => + _$MemoryStatsFromJson(json); +} + +@freezed +sealed class DiskStats with _$DiskStats { + const factory DiskStats({ + @JsonKey(name: 'mount_point') required String mountPoint, + required String device, + required String fstype, + @JsonKey(name: 'usage_percent') required double usagePercent, + @JsonKey(name: 'total_bytes') required int totalBytes, + @JsonKey(name: 'used_bytes') required int usedBytes, + @JsonKey(name: 'free_bytes') required int freeBytes, + }) = _DiskStats; + + factory DiskStats.fromJson(Map json) => + _$DiskStatsFromJson(json); +} + +@freezed +sealed class NetworkStats with _$NetworkStats { + const factory NetworkStats({ + @JsonKey(name: 'bytes_sent') required int bytesSent, + @JsonKey(name: 'bytes_recv') required int bytesRecv, + @JsonKey(name: 'bytes_total') required int bytesTotal, + }) = _NetworkStats; + + factory NetworkStats.fromJson(Map json) => + _$NetworkStatsFromJson(json); +} + +@freezed +sealed class GpuStats with _$GpuStats { + const factory GpuStats({ + required bool available, + String? name, + @JsonKey(name: 'usage_percent') double? usagePercent, + @JsonKey(name: 'total_bytes') int? totalBytes, + @JsonKey(name: 'used_bytes') int? usedBytes, + @JsonKey(name: 'free_bytes') int? freeBytes, + }) = _GpuStats; + + factory GpuStats.fromJson(Map json) => + _$GpuStatsFromJson(json); +} diff --git a/lib/features/front_hall/presentation/providers/system_stats_provider.dart b/lib/features/front_hall/presentation/providers/system_stats_provider.dart new file mode 100644 index 0000000..9518ad8 --- /dev/null +++ b/lib/features/front_hall/presentation/providers/system_stats_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart'; + +part 'system_stats_provider.g.dart'; + +/// Fetches system stats from Core API. +@riverpod +Future systemStats(Ref ref) async { + final dio = ref.watch(coreApiClientProvider); + + final response = await dio.get('/tools/system/stats'); + return SystemStats.fromJson(response.data as Map); +} diff --git a/lib/features/front_hall/presentation/widgets/dashboard_content.dart b/lib/features/front_hall/presentation/widgets/dashboard_content.dart index f034c26..024d1fc 100644 --- a/lib/features/front_hall/presentation/widgets/dashboard_content.dart +++ b/lib/features/front_hall/presentation/widgets/dashboard_content.dart @@ -1,16 +1,45 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart'; +import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart'; import 'package:tatlock_ui/shared/widgets/widgets.dart'; import 'package:tatlock_ui/version.g.dart'; /// Dashboard content shown in Front Hall when mode is dashboard. /// /// Displays system stats with gauges, weather, air quality, and version info. -class DashboardContent extends StatelessWidget { +/// Auto-refreshes system stats every 30 seconds. +class DashboardContent extends ConsumerStatefulWidget { const DashboardContent({super.key}); + @override + ConsumerState createState() => _DashboardContentState(); +} + +class _DashboardContentState extends ConsumerState { + Timer? _refreshTimer; + + @override + void initState() { + super.initState(); + _refreshTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => ref.invalidate(systemStatsProvider), + ); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final systemStatsAsync = ref.watch(systemStatsProvider); return ListView( padding: const EdgeInsets.all(16), @@ -54,37 +83,33 @@ class DashboardContent extends StatelessWidget { // System Stats - Gauges _SectionHeader(title: 'System Stats', icon: Icons.monitor_heart), const SizedBox(height: 8), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: GaugeRow( - gaugeSize: 90, - gauges: [ - GaugeData( - value: 0.35, - label: 'CPU', - icon: Icons.memory, - color: colorScheme.primary, - ), - GaugeData( - value: 0.62, - label: 'Memory', - icon: Icons.storage, - color: colorScheme.secondary, - ), - GaugeData( - value: 0.78, - label: 'Disk', - icon: Icons.disc_full, - color: colorScheme.tertiary, - ), - GaugeData( - value: 0.12, - label: 'Network', - icon: Icons.wifi, - color: Colors.teal, - ), - ], + systemStatsAsync.when( + data: (stats) => _SystemStatsCard(stats: stats), + loading: () => const Card( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + error: (error, _) => Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Failed to load system stats', + style: TextStyle(color: colorScheme.error), + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(systemStatsProvider), + ), + ], + ), ), ), ), @@ -165,3 +190,80 @@ class _SectionHeader extends StatelessWidget { ); } } + +/// Card displaying system stats with gauges. +class _SystemStatsCard extends StatelessWidget { + const _SystemStatsCard({required this.stats}); + + final SystemStats stats; + + /// Pastel colors based on usage percentage. + static const _pastelGreen = Color(0xFF81C784); + static const _pastelOrange = Color(0xFFFFB74D); + static const _pastelRed = Color(0xFFE57373); + + /// Returns color based on usage: green ≤50%, orange 51-75%, red >75%. + Color _colorForUsage(double percent) { + if (percent <= 50) return _pastelGreen; + if (percent <= 75) return _pastelOrange; + return _pastelRed; + } + + @override + Widget build(BuildContext context) { + // Build gauges list: CPU, Memory, GPU (if available), then all disks + final gauges = [ + GaugeData( + value: stats.cpu.usagePercent / 100, + label: 'CPU', + icon: Icons.memory, + color: _colorForUsage(stats.cpu.usagePercent), + ), + GaugeData( + value: stats.memory.usagePercent / 100, + label: 'RAM', + icon: Icons.storage, + color: _colorForUsage(stats.memory.usagePercent), + ), + if (stats.gpu.available && stats.gpu.usagePercent != null) + GaugeData( + value: stats.gpu.usagePercent! / 100, + label: 'VRAM', + icon: Icons.videocam, + color: _colorForUsage(stats.gpu.usagePercent!), + ), + // Add a gauge for each disk + ...stats.disks.map( + (disk) => GaugeData( + value: disk.usagePercent / 100, + label: _formatDiskLabel(disk), + icon: Icons.disc_full, + color: _colorForUsage(disk.usagePercent), + ), + ), + ]; + + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: GaugeRow( + gaugeSize: 120, + gauges: gauges, + ), + ), + ); + } + + /// Formats disk label from mount point. + String _formatDiskLabel(DiskStats disk) { + final mount = disk.mountPoint; + if (mount == '/') return 'Root'; + if (mount == '/hostfs') return 'Host'; + if (mount.startsWith('/hostfs/')) return mount.substring(8); + if (mount.startsWith('/mnt/')) return mount.substring(5); + if (mount.startsWith('/media/')) return mount.substring(7); + // Return last path segment + final parts = mount.split('/'); + return parts.isNotEmpty ? parts.last : mount; + } +} diff --git a/lib/shared/widgets/gauge_widget.dart b/lib/shared/widgets/gauge_widget.dart index 6995a3e..e89ea94 100644 --- a/lib/shared/widgets/gauge_widget.dart +++ b/lib/shared/widgets/gauge_widget.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -/// A circular gauge widget for displaying percentage values. +/// A speedometer-style gauge widget for displaying percentage values. /// /// Commonly used for system stats like CPU, Memory, Disk usage. class GaugeWidget extends StatelessWidget { @@ -52,15 +52,19 @@ class GaugeWidget extends StatelessWidget { // Clamp value between 0 and 1 final clampedValue = value.clamp(0.0, 1.0); + // Height is smaller since we only draw half circle + final gaugeHeight = size * 0.6; + final iconSpace = icon != null ? size * 0.36 : 0.0; + return SizedBox( width: size, - height: size + 24, // Extra space for label + height: gaugeHeight + 32 + iconSpace, // Space for label + icon child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: size, - height: size, + height: gaugeHeight, child: CustomPaint( painter: _GaugePainter( value: clampedValue, @@ -68,32 +72,21 @@ class GaugeWidget extends StatelessWidget { backgroundColor: effectiveBackgroundColor, strokeWidth: strokeWidth, ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon( - icon, - size: size * 0.2, - color: effectiveColor, - ), - SizedBox(height: size * 0.02), - ], - if (showPercentage) - Text( + child: Align( + alignment: const Alignment(0, 0.6), + child: showPercentage + ? Text( '${(clampedValue * 100).round()}%', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, - fontSize: size * 0.18, + fontSize: size * 0.2, ), - ), - ], - ), + ) + : const SizedBox.shrink(), ), ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -103,6 +96,14 @@ class GaugeWidget extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), + if (icon != null) ...[ + const SizedBox(height: 8), + Icon( + icon, + size: size * 0.28, + color: effectiveColor, + ), + ], ], ), ); @@ -124,12 +125,13 @@ class _GaugePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); + // Center at bottom of widget for speedometer style + final center = Offset(size.width / 2, size.height); final radius = (size.width - strokeWidth) / 2; - // Start from top (-90 degrees) and sweep clockwise - const startAngle = -math.pi / 2; - const sweepAngle = 2 * math.pi; + // Speedometer arc: starts from left (180°) and sweeps 180° to right + const startAngle = math.pi; // 180 degrees (left side) + const sweepAngle = math.pi; // 180 degrees sweep (semicircle) // Background arc final backgroundPaint = Paint() @@ -187,7 +189,7 @@ class GaugeRow extends StatelessWidget { @override Widget build(BuildContext context) { return Wrap( - spacing: 16, + spacing: 24, runSpacing: 16, alignment: WrapAlignment.center, children: gauges @@ -196,6 +198,7 @@ class GaugeRow extends StatelessWidget { value: data.value, label: data.label, size: gaugeSize, + strokeWidth: gaugeSize * 0.1, color: data.color, icon: data.icon, ), diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart index 348eb08..7d17673 100644 --- a/lib/shared/widgets/weather_widget.dart +++ b/lib/shared/widgets/weather_widget.dart @@ -36,15 +36,15 @@ class WeatherWidget extends StatelessWidget { Row( children: [ Icon( - Icons.location_on, - size: 16, + Icons.wb_sunny_outlined, + size: 20, color: colorScheme.onSurfaceVariant, ), - const SizedBox(width: 4), + const SizedBox(width: 8), Expanded( child: Text( weather.location, - style: Theme.of(context).textTheme.bodySmall?.copyWith( + style: Theme.of(context).textTheme.titleSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), maxLines: 1, @@ -53,18 +53,36 @@ class WeatherWidget extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 16), - // Main weather display + // Main weather display (matching AQI layout) Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - weather.icon, - size: 48, - color: weather.iconColor ?? colorScheme.primary, + // Weather icon in box (like AQI number box) + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: (weather.iconColor ?? colorScheme.primary) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: (weather.iconColor ?? colorScheme.primary) + .withValues(alpha: 0.3), + width: 2, + ), + ), + child: Center( + child: Icon( + weather.icon, + size: 32, + color: weather.iconColor ?? colorScheme.primary, + ), + ), ), - const SizedBox(width: 12), + const SizedBox(width: 16), + + // Temperature and condition Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -72,15 +90,18 @@ class WeatherWidget extends StatelessWidget { Text( '${weather.temperature.round()}°${weather.unit.symbol}', style: - Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 4), Text( weather.condition, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), @@ -90,36 +111,27 @@ class WeatherWidget extends StatelessWidget { // Details if (weather.humidity != null || weather.windSpeed != null) ...[ - const SizedBox(height: 12), + const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 12), - Row( + Wrap( + spacing: 16, + runSpacing: 8, children: [ if (weather.humidity != null) - Expanded( - child: _DetailItem( - icon: Icons.water_drop_outlined, - label: 'Humidity', - value: '${weather.humidity}%', - ), + _DetailChip( + label: 'Humidity', + value: '${weather.humidity}%', ), if (weather.windSpeed != null) - Expanded( - child: _DetailItem( - icon: Icons.air, - label: 'Wind', - value: - '${weather.windSpeed!.round()} ${weather.windUnit}', - ), + _DetailChip( + label: 'Wind', + value: '${weather.windSpeed!.round()} ${weather.windUnit}', ), if (weather.feelsLike != null) - Expanded( - child: _DetailItem( - icon: Icons.thermostat, - label: 'Feels like', - value: - '${weather.feelsLike!.round()}°${weather.unit.symbol}', - ), + _DetailChip( + label: 'Feels', + value: '${weather.feelsLike!.round()}°${weather.unit.symbol}', ), ], ), @@ -160,14 +172,12 @@ class WeatherWidget extends StatelessWidget { } } -class _DetailItem extends StatelessWidget { - const _DetailItem({ - required this.icon, +class _DetailChip extends StatelessWidget { + const _DetailChip({ required this.label, required this.value, }); - final IconData icon; final String label; final String value; @@ -178,29 +188,18 @@ class _DetailItem extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: 16, - color: colorScheme.onSurfaceVariant, + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.outline, + ), ), const SizedBox(width: 4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.outline, - fontSize: 10, - ), - ), - ], + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), ), ], );