flutter analyze exits non-zero on info-level findings too, so all five had to go for `make pre-push` to pass. Four were mechanical. The fifth was not. envApiUser was reported as an unused declaration. Removing it revealed that the field behind it, _envApiUser, was then unused as well -- and the pair turns out to be a closed loop nothing could enter: the getter is public but sits on _DashboardContentState, a private class, so no caller outside this file could ever have reached it. The field was written once per session and never read. The debugPrint next to it logs envData.user directly, so the logging the comment describes never depended on the stored copy. Field, getter and assignment removed; _hasLoggedEnvUser stays, because it genuinely guards the log-once. Deleting the first warning exposing the second is the useful part: unused_field could not fire while a dead getter was "using" it. Dead code hides dead code. The two `if (x != null) x` collection entries become null-aware elements, which is the same intent spelled the way the SDK now expects. The two casts in data_grid_test were the second cast of a pair -- `mode as InfiniteDataMode` on the preceding line already promotes the local. flutter analyze: No issues found. The edited test file still passes all 37. Note the gate still prints "not gated here yet: test (T-56)" -- analysis is green, tests remain unwired, and that is deliberately left visible. Co-Authored-By: Claude <noreply@anthropic.com>
400 lines
13 KiB
Dart
400 lines
13 KiB
Dart
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/environment_provider.dart';
|
|
import 'package:tatlock_ui/features/front_hall/presentation/providers/news_provider.dart';
|
|
import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart';
|
|
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
|
|
import 'package:tatlock_ui/shared/widgets/forecast_widget.dart';
|
|
import 'package:tatlock_ui/shared/widgets/sun_position_widget.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.
|
|
/// Auto-refreshes system stats every 30 seconds.
|
|
class DashboardContent extends ConsumerStatefulWidget {
|
|
const DashboardContent({super.key});
|
|
|
|
@override
|
|
ConsumerState<DashboardContent> createState() => _DashboardContentState();
|
|
}
|
|
|
|
class _DashboardContentState extends ConsumerState<DashboardContent> {
|
|
Timer? _systemStatsTimer;
|
|
Timer? _environmentTimer;
|
|
Timer? _newsTimer;
|
|
|
|
/// Tracks if we've logged the environment API user (log once per session)
|
|
static bool _hasLoggedEnvUser = false;
|
|
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Refresh system stats every 30 seconds
|
|
_systemStatsTimer = Timer.periodic(
|
|
const Duration(seconds: 30),
|
|
(_) => ref.invalidate(systemStatsProvider),
|
|
);
|
|
// Refresh environment data every hour
|
|
_environmentTimer = Timer.periodic(
|
|
const Duration(hours: 1),
|
|
(_) => ref.invalidate(environmentProvider),
|
|
);
|
|
// Refresh news every 30 minutes
|
|
_newsTimer = Timer.periodic(
|
|
const Duration(minutes: 30),
|
|
(_) => ref.invalidate(newsProvider),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_systemStatsTimer?.cancel();
|
|
_environmentTimer?.cancel();
|
|
_newsTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final systemStatsAsync = ref.watch(systemStatsProvider);
|
|
final environmentAsync = ref.watch(environmentProvider);
|
|
final newsAsync = ref.watch(newsProvider);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Welcome card
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.waving_hand,
|
|
size: 32,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: Text(
|
|
'Welcome to Tatlock',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Your homelab dashboard is ready.',
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// News Ticker - full width
|
|
newsAsync.when(
|
|
data: (newsData) => NewsTickerWidget(newsData: newsData),
|
|
loading: () => const NewsTickerWidget(),
|
|
error: (error, stack) => const NewsTickerWidget(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// System Stats - Gauges
|
|
_SectionHeader(title: 'System Stats', icon: Icons.monitor_heart),
|
|
const SizedBox(height: 8),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Environment - Sun, Weather, Forecast, Air Quality
|
|
const _SectionHeader(title: 'Environment', icon: Icons.eco),
|
|
const SizedBox(height: 8),
|
|
environmentAsync.when(
|
|
data: (envData) {
|
|
// Log the user once per session
|
|
if (!_hasLoggedEnvUser && envData.user != null) {
|
|
_hasLoggedEnvUser = true;
|
|
debugPrint('Environment API user: ${envData.user}');
|
|
}
|
|
return _EnvironmentSection(
|
|
envData: envData,
|
|
onRefresh: () => ref.invalidate(environmentProvider),
|
|
);
|
|
},
|
|
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 environment data',
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: () => ref.invalidate(environmentProvider),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Version info
|
|
Center(
|
|
child: Text(
|
|
'${AppVersion.name} v${AppVersion.fullVersion}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Section header with icon and title.
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader({
|
|
required this.title,
|
|
required this.icon,
|
|
});
|
|
|
|
final String title;
|
|
final IconData icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Card displaying system stats with gauges.
|
|
class _SystemStatsCard extends StatelessWidget {
|
|
const _SystemStatsCard({required this.stats});
|
|
|
|
final SystemStats stats;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Build gauges list: CPU, Memory, GPU (if available), then all disks
|
|
final gauges = <GaugeData>[
|
|
GaugeData(
|
|
value: stats.cpu.usagePercent / 100,
|
|
label: 'CPU',
|
|
icon: Icons.memory,
|
|
color: StoplightColors.forPercent(stats.cpu.usagePercent),
|
|
),
|
|
GaugeData(
|
|
value: stats.memory.usagePercent / 100,
|
|
label: 'RAM',
|
|
icon: Icons.storage,
|
|
color: StoplightColors.forPercent(stats.memory.usagePercent),
|
|
),
|
|
if (stats.gpu.available && stats.gpu.usagePercent != null)
|
|
GaugeData(
|
|
value: stats.gpu.usagePercent! / 100,
|
|
label: 'VRAM',
|
|
icon: Icons.videocam,
|
|
color: StoplightColors.forPercent(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: StoplightColors.forPercent(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;
|
|
}
|
|
}
|
|
|
|
/// Environment section displaying sun position, weather, air quality, and forecast.
|
|
///
|
|
/// Layout:
|
|
/// - Desktop (>900px): 4 widgets in a row - Sun(30%) | Weather(20%) | AirQuality(20%) | Forecast(30%)
|
|
/// - Tablet (600-900px): 2x2 grid
|
|
/// - Mobile (<600px): Stacked vertically
|
|
class _EnvironmentSection extends StatelessWidget {
|
|
const _EnvironmentSection({
|
|
required this.envData,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
final dynamic envData;
|
|
final VoidCallback onRefresh;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (constraints.maxWidth > 900) {
|
|
// Desktop: 4 in a row (30/20/20/30) - wider widgets on outsides
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
flex: 30,
|
|
child: SunPositionWidget(sunTimes: envData.sunTimes),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 20,
|
|
child: WeatherWidget(apiData: envData.weather),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 20,
|
|
child: AirQualityWidget(apiData: envData.airQuality),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 30,
|
|
child: ForecastWidget(forecast: envData.forecast),
|
|
),
|
|
],
|
|
);
|
|
} else if (constraints.maxWidth > 600) {
|
|
// Tablet: 2x2 grid
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: SunPositionWidget(sunTimes: envData.sunTimes),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: WeatherWidget(apiData: envData.weather)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: AirQualityWidget(apiData: envData.airQuality),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: ForecastWidget(forecast: envData.forecast)),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
// Mobile: stacked vertically
|
|
return Column(
|
|
children: [
|
|
SunPositionWidget(sunTimes: envData.sunTimes),
|
|
const SizedBox(height: 12),
|
|
WeatherWidget(apiData: envData.weather),
|
|
const SizedBox(height: 12),
|
|
AirQualityWidget(apiData: envData.airQuality),
|
|
const SizedBox(height: 12),
|
|
ForecastWidget(forecast: envData.forecast),
|
|
],
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|