chore: release v1.5.0
feat: add dynamic environment widgets Add live weather, sun position, and forecast widgets to Front Hall dashboard, powered by data from the Qdrant volatile collection via core-api. New widgets: - SunPositionWidget: Animated arc showing sun/moon position with gradient colors - ForecastWidget: Multi-day weather outlook - Updated WeatherWidget and AirQualityWidget to accept API data Infrastructure: - Environment datasource calling GET /tools/environment - Environment provider with 5-minute auto-refresh - Freezed models for environment data Tests: - 12 widget tests for environment section - Updated existing tests with givenEnvironment() harness method 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
ea6914b5f4
commit
fffc3d5baf
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Dynamic Environment Widgets** - Live weather, sun position, and forecast data from Qdrant
|
||||
- `SunPositionWidget` - Animated semicircle arc showing sun/moon position based on current time
|
||||
- Gradient colors: yellow for daytime, orange for sunrise/sunset, blue for night
|
||||
- Displays sunrise, sunset times and daylight duration
|
||||
- `WeatherWidget` - Current temperature, conditions, humidity from API
|
||||
- `ForecastWidget` - Multi-day weather forecast with conditions icons
|
||||
- `AirQualityWidget` - AQI display (only shown when data available)
|
||||
- Environment data provider with auto-refresh every 5 minutes
|
||||
- Environment datasource calling `GET /tools/environment`
|
||||
- Freezed models for environment data (weather, forecast, sun times, air quality)
|
||||
- Comprehensive widget tests for environment section
|
||||
|
||||
### Changed
|
||||
|
||||
- Dashboard layout now displays dynamic environment data instead of static widgets
|
||||
- Weather and air quality widgets accept optional API data parameters
|
||||
|
||||
## [1.4.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
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/environment_model.dart';
|
||||
|
||||
part 'environment_datasource.g.dart';
|
||||
|
||||
/// Data source for environment data operations.
|
||||
///
|
||||
/// Fetches weather, forecast, sun times, and air quality from Core API.
|
||||
class EnvironmentDatasource {
|
||||
EnvironmentDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/tools/environment';
|
||||
|
||||
/// Gets current environment data.
|
||||
///
|
||||
/// Returns weather, forecast, sun times, and optionally air quality.
|
||||
Future<EnvironmentData> getEnvironment() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(_basePath);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to fetch environment data');
|
||||
}
|
||||
|
||||
return EnvironmentData.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the environment datasource.
|
||||
@riverpod
|
||||
EnvironmentDatasource environmentDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return EnvironmentDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'environment_model.freezed.dart';
|
||||
part 'environment_model.g.dart';
|
||||
|
||||
/// Environment data response from Core API.
|
||||
/// Contains weather, forecast, sun times, and optionally air quality.
|
||||
@freezed
|
||||
sealed class EnvironmentData with _$EnvironmentData {
|
||||
const factory EnvironmentData({
|
||||
WeatherData? weather,
|
||||
List<ForecastDay>? forecast,
|
||||
@JsonKey(name: 'sun_times') SunTimesData? sunTimes,
|
||||
@JsonKey(name: 'air_quality') AirQualityData? airQuality,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
String? user,
|
||||
}) = _EnvironmentData;
|
||||
|
||||
factory EnvironmentData.fromJson(Map<String, dynamic> json) =>
|
||||
_$EnvironmentDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Current weather conditions.
|
||||
@freezed
|
||||
sealed class WeatherData with _$WeatherData {
|
||||
const factory WeatherData({
|
||||
double? temperature,
|
||||
@JsonKey(name: 'feels_like') double? feelsLike,
|
||||
String? conditions,
|
||||
int? humidity,
|
||||
@JsonKey(name: 'wind_speed') double? windSpeed,
|
||||
@JsonKey(name: 'wind_direction') String? windDirection,
|
||||
double? pressure,
|
||||
double? visibility,
|
||||
@JsonKey(name: 'uv_index') double? uvIndex,
|
||||
String? location,
|
||||
String? icon,
|
||||
}) = _WeatherData;
|
||||
|
||||
factory WeatherData.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeatherDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Single day forecast data.
|
||||
@freezed
|
||||
sealed class ForecastDay with _$ForecastDay {
|
||||
const factory ForecastDay({
|
||||
required String date,
|
||||
double? high,
|
||||
double? low,
|
||||
String? conditions,
|
||||
@JsonKey(name: 'precipitation_chance') int? precipitationChance,
|
||||
String? icon,
|
||||
}) = _ForecastDay;
|
||||
|
||||
factory ForecastDay.fromJson(Map<String, dynamic> json) =>
|
||||
_$ForecastDayFromJson(json);
|
||||
}
|
||||
|
||||
/// Sunrise and sunset times.
|
||||
@freezed
|
||||
sealed class SunTimesData with _$SunTimesData {
|
||||
const factory SunTimesData({
|
||||
DateTime? sunrise,
|
||||
DateTime? sunset,
|
||||
@JsonKey(name: 'daylight_minutes') int? daylightMinutes,
|
||||
@JsonKey(name: 'solar_noon') DateTime? solarNoon,
|
||||
DateTime? dawn,
|
||||
DateTime? dusk,
|
||||
}) = _SunTimesData;
|
||||
|
||||
factory SunTimesData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SunTimesDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Air quality information.
|
||||
@freezed
|
||||
sealed class AirQualityData with _$AirQualityData {
|
||||
const factory AirQualityData({
|
||||
int? aqi,
|
||||
String? quality,
|
||||
double? pm25,
|
||||
double? pm10,
|
||||
double? o3,
|
||||
double? no2,
|
||||
String? location,
|
||||
}) = _AirQualityData;
|
||||
|
||||
factory AirQualityData.fromJson(Map<String, dynamic> json) =>
|
||||
_$AirQualityDataFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/datasources/environment_datasource.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
part 'environment_provider.g.dart';
|
||||
|
||||
/// Fetches environment data (weather, forecast, sun times) from Core API.
|
||||
///
|
||||
/// Auto-invalidates every 5 minutes to keep data fresh.
|
||||
/// Weather data in Qdrant has 1-hour TTL, so 5-minute refresh is reasonable.
|
||||
@riverpod
|
||||
Future<EnvironmentData> environment(Ref ref) async {
|
||||
final datasource = ref.watch(environmentDatasourceProvider);
|
||||
return datasource.getEnvironment();
|
||||
}
|
||||
|
||||
/// Provides whether air quality data is available.
|
||||
///
|
||||
/// Used for conditional rendering of AirQualityWidget.
|
||||
@riverpod
|
||||
bool hasAirQuality(Ref ref) {
|
||||
final asyncValue = ref.watch(environmentProvider);
|
||||
return asyncValue.maybeWhen(
|
||||
data: (data) => data.airQuality != null,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@ 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/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';
|
||||
|
||||
@@ -20,20 +23,28 @@ class DashboardContent extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
Timer? _refreshTimer;
|
||||
Timer? _systemStatsTimer;
|
||||
Timer? _environmentTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshTimer = Timer.periodic(
|
||||
// Refresh system stats every 30 seconds
|
||||
_systemStatsTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => ref.invalidate(systemStatsProvider),
|
||||
);
|
||||
// Refresh environment data every 5 minutes
|
||||
_environmentTimer = Timer.periodic(
|
||||
const Duration(minutes: 5),
|
||||
(_) => ref.invalidate(environmentProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_systemStatsTimer?.cancel();
|
||||
_environmentTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -41,6 +52,7 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final systemStatsAsync = ref.watch(systemStatsProvider);
|
||||
final environmentAsync = ref.watch(environmentProvider);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -116,31 +128,41 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Environment - Weather & Air Quality
|
||||
// Environment - Sun, Weather, Forecast, Air Quality
|
||||
_SectionHeader(title: 'Environment', icon: Icons.eco),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive layout: side-by-side on wider screens
|
||||
if (constraints.maxWidth > 500) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
environmentAsync.when(
|
||||
data: (envData) => _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: [
|
||||
Expanded(child: WeatherWidget()),
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: AirQualityWidget()),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Failed to load environment data',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.invalidate(environmentProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Stack on narrow screens
|
||||
return Column(
|
||||
children: [
|
||||
WeatherWidget(),
|
||||
const SizedBox(height: 12),
|
||||
AirQualityWidget(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -256,3 +278,56 @@ class _SystemStatsCard extends StatelessWidget {
|
||||
return parts.isNotEmpty ? parts.last : mount;
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment section displaying sun position, weather, forecast, and air quality.
|
||||
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) {
|
||||
final isWide = constraints.maxWidth > 600;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Sun Position Widget - prominent at top
|
||||
SunPositionWidget(sunTimes: envData.sunTimes),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Weather and Forecast side-by-side on wide screens
|
||||
if (isWide)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: WeatherWidget(apiData: envData.weather)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: ForecastWidget(forecast: envData.forecast)),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
WeatherWidget(apiData: envData.weather),
|
||||
const SizedBox(height: 12),
|
||||
ForecastWidget(forecast: envData.forecast),
|
||||
],
|
||||
),
|
||||
|
||||
// Air Quality - only show if data is available
|
||||
if (envData.airQuality != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
AirQualityWidget(apiData: envData.airQuality),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'
|
||||
as api;
|
||||
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
|
||||
|
||||
/// Air Quality Index widget displaying current AQI.
|
||||
///
|
||||
/// Currently uses mock data. Will be connected to air quality API in future.
|
||||
/// Displays air quality data from the Core API environment endpoint.
|
||||
/// Only render this widget when air quality data is available.
|
||||
class AirQualityWidget extends StatelessWidget {
|
||||
const AirQualityWidget({
|
||||
super.key,
|
||||
this.apiData,
|
||||
this.data,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// Air quality data to display. Uses mock data if null.
|
||||
/// Air quality data from API. Takes priority over legacy data.
|
||||
final api.AirQualityData? apiData;
|
||||
|
||||
/// Legacy air quality data to display. Uses mock data if null.
|
||||
final AirQualityData? data;
|
||||
|
||||
/// Whether to use compact layout.
|
||||
@@ -20,7 +27,11 @@ class AirQualityWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final aqi = data ?? AirQualityData.mock();
|
||||
|
||||
// Convert API data to local model, or use legacy data
|
||||
final aqi = apiData != null
|
||||
? _fromApiData(apiData!)
|
||||
: (data ?? AirQualityData.mock());
|
||||
|
||||
if (compact) {
|
||||
return _buildCompact(context, colorScheme, aqi);
|
||||
@@ -199,6 +210,31 @@ class _PollutantChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts API air quality data to local AirQualityData model.
|
||||
AirQualityData _fromApiData(api.AirQualityData data) {
|
||||
final index = data.aqi ?? 0;
|
||||
final pollutants = <Pollutant>[];
|
||||
|
||||
if (data.pm25 != null) {
|
||||
pollutants.add(Pollutant(name: 'PM2.5', value: data.pm25!, unit: 'µg/m³'));
|
||||
}
|
||||
if (data.pm10 != null) {
|
||||
pollutants.add(Pollutant(name: 'PM10', value: data.pm10!, unit: 'µg/m³'));
|
||||
}
|
||||
if (data.o3 != null) {
|
||||
pollutants.add(Pollutant(name: 'O₃', value: data.o3!, unit: 'ppb'));
|
||||
}
|
||||
if (data.no2 != null) {
|
||||
pollutants.add(Pollutant(name: 'NO₂', value: data.no2!, unit: 'ppb'));
|
||||
}
|
||||
|
||||
return AirQualityData(
|
||||
index: index,
|
||||
level: AqiLevel.fromIndex(index),
|
||||
pollutants: pollutants,
|
||||
);
|
||||
}
|
||||
|
||||
/// Air Quality Index levels based on US EPA standard.
|
||||
enum AqiLevel {
|
||||
good(
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
/// Forecast widget displaying multi-day weather outlook.
|
||||
///
|
||||
/// Shows a horizontal scrollable list of forecast days with
|
||||
/// high/low temperatures and weather icons.
|
||||
class ForecastWidget extends StatelessWidget {
|
||||
const ForecastWidget({
|
||||
super.key,
|
||||
this.forecast,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// Forecast data to display.
|
||||
final List<ForecastDay>? forecast;
|
||||
|
||||
/// Whether to use compact layout.
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (forecast == null || forecast!.isEmpty) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No forecast data available',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return _buildCompact(context, colorScheme);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Forecast',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Forecast days - horizontal scroll
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: forecast!.length,
|
||||
separatorBuilder: (_, i) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return _ForecastDayCard(day: forecast![index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompact(BuildContext context, ColorScheme colorScheme) {
|
||||
// Show just first 3 days in compact mode
|
||||
final days = forecast!.take(3).toList();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...days.map((day) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getDayName(day.date),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Icon(
|
||||
_getWeatherIcon(day.conditions),
|
||||
size: 16,
|
||||
color: _getWeatherColor(day.conditions),
|
||||
),
|
||||
Text(
|
||||
'${day.high?.round() ?? '--'}°',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDayName(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
if (date.day == now.day &&
|
||||
date.month == now.month &&
|
||||
date.year == now.year) {
|
||||
return 'Today';
|
||||
}
|
||||
final tomorrow = now.add(const Duration(days: 1));
|
||||
if (date.day == tomorrow.day &&
|
||||
date.month == tomorrow.month &&
|
||||
date.year == tomorrow.year) {
|
||||
return 'Tmrw';
|
||||
}
|
||||
return DateFormat('E').format(date);
|
||||
} catch (_) {
|
||||
return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getWeatherIcon(String? conditions) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Icons.wb_sunny;
|
||||
} else if (condition.contains('cloud') || condition.contains('overcast')) {
|
||||
return Icons.cloud;
|
||||
} else if (condition.contains('rain') || condition.contains('drizzle')) {
|
||||
return Icons.grain;
|
||||
} else if (condition.contains('storm') || condition.contains('thunder')) {
|
||||
return Icons.thunderstorm;
|
||||
} else if (condition.contains('snow') || condition.contains('sleet')) {
|
||||
return Icons.ac_unit;
|
||||
} else if (condition.contains('fog') || condition.contains('mist')) {
|
||||
return Icons.blur_on;
|
||||
}
|
||||
return Icons.cloud;
|
||||
}
|
||||
|
||||
Color? _getWeatherColor(String? conditions) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Colors.amber;
|
||||
} else if (condition.contains('cloud')) {
|
||||
return Colors.blueGrey;
|
||||
} else if (condition.contains('rain')) {
|
||||
return Colors.blue;
|
||||
} else if (condition.contains('storm')) {
|
||||
return Colors.deepPurple;
|
||||
} else if (condition.contains('snow')) {
|
||||
return Colors.lightBlue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastDayCard extends StatelessWidget {
|
||||
const _ForecastDayCard({required this.day});
|
||||
|
||||
final ForecastDay day;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon = _getWeatherIcon(day.conditions);
|
||||
final color = _getWeatherColor(day.conditions) ?? colorScheme.primary;
|
||||
|
||||
return Container(
|
||||
width: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Day name
|
||||
Text(
|
||||
_getDayName(day.date),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
// Weather icon
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: color,
|
||||
),
|
||||
|
||||
// High/Low temps
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'${day.high?.round() ?? '--'}°',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${day.low?.round() ?? '--'}°',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDayName(String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
if (date.day == now.day &&
|
||||
date.month == now.month &&
|
||||
date.year == now.year) {
|
||||
return 'Today';
|
||||
}
|
||||
final tomorrow = now.add(const Duration(days: 1));
|
||||
if (date.day == tomorrow.day &&
|
||||
date.month == tomorrow.month &&
|
||||
date.year == tomorrow.year) {
|
||||
return 'Tmrw';
|
||||
}
|
||||
return DateFormat('EEE').format(date);
|
||||
} catch (_) {
|
||||
return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getWeatherIcon(String? conditions) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Icons.wb_sunny;
|
||||
} else if (condition.contains('cloud') || condition.contains('overcast')) {
|
||||
return Icons.cloud;
|
||||
} else if (condition.contains('rain') || condition.contains('drizzle')) {
|
||||
return Icons.grain;
|
||||
} else if (condition.contains('storm') || condition.contains('thunder')) {
|
||||
return Icons.thunderstorm;
|
||||
} else if (condition.contains('snow') || condition.contains('sleet')) {
|
||||
return Icons.ac_unit;
|
||||
} else if (condition.contains('fog') || condition.contains('mist')) {
|
||||
return Icons.blur_on;
|
||||
}
|
||||
return Icons.cloud;
|
||||
}
|
||||
|
||||
Color? _getWeatherColor(String? conditions) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Colors.amber;
|
||||
} else if (condition.contains('cloud')) {
|
||||
return Colors.blueGrey;
|
||||
} else if (condition.contains('rain')) {
|
||||
return Colors.blue;
|
||||
} else if (condition.contains('storm')) {
|
||||
return Colors.deepPurple;
|
||||
} else if (condition.contains('snow')) {
|
||||
return Colors.lightBlue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
/// Sun position widget displaying sunrise/sunset with animated arc.
|
||||
///
|
||||
/// Shows a semicircle arc representing the day, with sun/moon icon
|
||||
/// moving along based on current time. Uses color gradients:
|
||||
/// - Blue tones for night
|
||||
/// - Orange tones for sunrise/sunset
|
||||
/// - Yellow tones for daytime
|
||||
class SunPositionWidget extends StatelessWidget {
|
||||
const SunPositionWidget({
|
||||
super.key,
|
||||
this.sunTimes,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// Sun times data to display.
|
||||
final SunTimesData? sunTimes;
|
||||
|
||||
/// Whether to use compact layout.
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (compact) {
|
||||
return _buildCompact(context, colorScheme);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isDaytime ? Icons.wb_sunny : Icons.nightlight_round,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Sun Position',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Arc visualization
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 120),
|
||||
painter: _SunArcPainter(
|
||||
sunTimes: sunTimes,
|
||||
isDark: colorScheme.brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Sunrise/Sunset times
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_TimeDisplay(
|
||||
icon: Icons.wb_twilight,
|
||||
label: 'Sunrise',
|
||||
time: sunTimes?.sunrise,
|
||||
iconColor: Colors.orange,
|
||||
),
|
||||
if (sunTimes?.daylightMinutes != null)
|
||||
_DaylightDisplay(minutes: sunTimes!.daylightMinutes!),
|
||||
_TimeDisplay(
|
||||
icon: Icons.nights_stay,
|
||||
label: 'Sunset',
|
||||
time: sunTimes?.sunset,
|
||||
iconColor: Colors.deepOrange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompact(BuildContext context, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_isDaytime ? Icons.wb_sunny : Icons.nightlight_round,
|
||||
size: 24,
|
||||
color: _isDaytime ? Colors.amber : Colors.indigo,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(sunTimes?.sunrise),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
_formatTime(sunTimes?.sunset),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isDaytime {
|
||||
if (sunTimes?.sunrise == null || sunTimes?.sunset == null) return true;
|
||||
final now = DateTime.now();
|
||||
return now.isAfter(sunTimes!.sunrise!) && now.isBefore(sunTimes!.sunset!);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '--:--';
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeDisplay extends StatelessWidget {
|
||||
const _TimeDisplay({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.time,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final DateTime? time;
|
||||
final Color? iconColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: iconColor ?? colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatTime(time),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '--:--';
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _DaylightDisplay extends StatelessWidget {
|
||||
const _DaylightDisplay({required this.minutes});
|
||||
|
||||
final int minutes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hours = minutes ~/ 60;
|
||||
final mins = minutes % 60;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${hours}h ${mins}m',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Daylight',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for the sun arc visualization.
|
||||
class _SunArcPainter extends CustomPainter {
|
||||
_SunArcPainter({
|
||||
this.sunTimes,
|
||||
this.isDark = false,
|
||||
});
|
||||
|
||||
final SunTimesData? sunTimes;
|
||||
final bool isDark;
|
||||
|
||||
// Color palette
|
||||
static const _nightBlue = Color(0xFF1a237e);
|
||||
static const _dawnOrange = Color(0xFFff6f00);
|
||||
static const _sunriseOrange = Color(0xFFffa000);
|
||||
static const _dayYellow = Color(0xFFffc107);
|
||||
static const _dayGold = Color(0xFFffb300);
|
||||
static const _duskOrange = Color(0xFFef6c00);
|
||||
static const _nightIndigo = Color(0xFF283593);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height - 10);
|
||||
final radius = math.min(size.width / 2 - 20, size.height - 30);
|
||||
|
||||
// Draw horizon line
|
||||
final horizonPaint = Paint()
|
||||
..color = isDark ? Colors.white24 : Colors.black12
|
||||
..strokeWidth = 1;
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - radius - 10, center.dy),
|
||||
Offset(center.dx + radius + 10, center.dy),
|
||||
horizonPaint,
|
||||
);
|
||||
|
||||
// Create gradient for arc background
|
||||
final arcRect = Rect.fromCenter(
|
||||
center: center,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
);
|
||||
|
||||
// Draw arc background (night portion below horizon implied)
|
||||
final arcBgPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Create gradient that transitions through time of day colors
|
||||
arcBgPaint.shader = SweepGradient(
|
||||
center: Alignment.bottomCenter,
|
||||
startAngle: math.pi,
|
||||
endAngle: 2 * math.pi,
|
||||
colors: [
|
||||
_nightBlue.withValues(alpha: 0.3),
|
||||
_dawnOrange.withValues(alpha: 0.5),
|
||||
_sunriseOrange.withValues(alpha: 0.6),
|
||||
_dayYellow.withValues(alpha: 0.7),
|
||||
_dayGold.withValues(alpha: 0.7),
|
||||
_dayYellow.withValues(alpha: 0.6),
|
||||
_duskOrange.withValues(alpha: 0.5),
|
||||
_nightIndigo.withValues(alpha: 0.3),
|
||||
],
|
||||
stops: const [0.0, 0.1, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0],
|
||||
transform: const GradientRotation(-math.pi / 2),
|
||||
).createShader(arcRect);
|
||||
|
||||
// Draw the arc (semicircle from left to right)
|
||||
canvas.drawArc(
|
||||
arcRect,
|
||||
math.pi, // Start at left (180 degrees)
|
||||
math.pi, // Sweep 180 degrees (semicircle)
|
||||
false,
|
||||
arcBgPaint,
|
||||
);
|
||||
|
||||
// Calculate sun position
|
||||
final sunPosition = _calculateSunPosition();
|
||||
|
||||
// Draw sun/moon indicator
|
||||
final angle = math.pi + (sunPosition * math.pi); // Map 0-1 to pi-2pi
|
||||
final sunX = center.dx + radius * math.cos(angle);
|
||||
final sunY = center.dy + radius * math.sin(angle);
|
||||
|
||||
// Determine if day or night for icon/color
|
||||
final isDaytime = sunPosition > 0 && sunPosition < 1;
|
||||
final isNearHorizon = sunPosition < 0.15 || sunPosition > 0.85;
|
||||
|
||||
// Sun/moon glow
|
||||
if (isDaytime) {
|
||||
final glowColor = isNearHorizon ? _dawnOrange : _dayYellow;
|
||||
final glowPaint = Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
glowColor.withValues(alpha: 0.6),
|
||||
glowColor.withValues(alpha: 0.0),
|
||||
],
|
||||
).createShader(
|
||||
Rect.fromCircle(center: Offset(sunX, sunY), radius: 24),
|
||||
);
|
||||
canvas.drawCircle(Offset(sunX, sunY), 24, glowPaint);
|
||||
}
|
||||
|
||||
// Sun/moon circle
|
||||
final sunPaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = isDaytime
|
||||
? (isNearHorizon ? _sunriseOrange : _dayYellow)
|
||||
: _nightIndigo;
|
||||
|
||||
canvas.drawCircle(Offset(sunX, sunY), 12, sunPaint);
|
||||
|
||||
// Sun rays or moon craters
|
||||
if (isDaytime) {
|
||||
final rayPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = (isNearHorizon ? _dawnOrange : _dayGold).withValues(alpha: 0.8);
|
||||
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final rayAngle = (i * math.pi / 4);
|
||||
final innerRadius = 14.0;
|
||||
final outerRadius = 18.0;
|
||||
canvas.drawLine(
|
||||
Offset(
|
||||
sunX + innerRadius * math.cos(rayAngle),
|
||||
sunY + innerRadius * math.sin(rayAngle),
|
||||
),
|
||||
Offset(
|
||||
sunX + outerRadius * math.cos(rayAngle),
|
||||
sunY + outerRadius * math.sin(rayAngle),
|
||||
),
|
||||
rayPaint,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Moon highlight
|
||||
final moonHighlight = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = Colors.white24;
|
||||
canvas.drawCircle(Offset(sunX - 3, sunY - 3), 4, moonHighlight);
|
||||
}
|
||||
|
||||
// Draw time markers on arc
|
||||
_drawTimeMarkers(canvas, center, radius);
|
||||
}
|
||||
|
||||
double _calculateSunPosition() {
|
||||
if (sunTimes?.sunrise == null || sunTimes?.sunset == null) {
|
||||
// Default to noon position
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final sunrise = sunTimes!.sunrise!;
|
||||
final sunset = sunTimes!.sunset!;
|
||||
|
||||
// Before sunrise
|
||||
if (now.isBefore(sunrise)) {
|
||||
// Calculate position in pre-dawn (negative values = below horizon)
|
||||
final midnight = DateTime(now.year, now.month, now.day);
|
||||
final minutesSinceMidnight = now.difference(midnight).inMinutes;
|
||||
final minutesToSunrise = sunrise.difference(midnight).inMinutes;
|
||||
// Map to 0 at sunrise, negative before
|
||||
return (minutesSinceMidnight / minutesToSunrise) * 0.15 - 0.1;
|
||||
}
|
||||
|
||||
// After sunset
|
||||
if (now.isAfter(sunset)) {
|
||||
// Calculate position in post-dusk
|
||||
final minutesSinceSunset = now.difference(sunset).inMinutes;
|
||||
final nextMidnight = DateTime(now.year, now.month, now.day + 1);
|
||||
final minutesToMidnight = nextMidnight.difference(sunset).inMinutes;
|
||||
// Map to 1 at sunset, going towards negative
|
||||
return 1.0 + (minutesSinceSunset / minutesToMidnight) * 0.1;
|
||||
}
|
||||
|
||||
// During daylight hours
|
||||
final totalDaylight = sunset.difference(sunrise).inMinutes;
|
||||
final minutesSinceSunrise = now.difference(sunrise).inMinutes;
|
||||
return minutesSinceSunrise / totalDaylight;
|
||||
}
|
||||
|
||||
void _drawTimeMarkers(Canvas canvas, Offset center, double radius) {
|
||||
final textPainter = TextPainter(
|
||||
textDirection: TextDirection.ltr,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
final markerStyle = TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? Colors.white38 : Colors.black38,
|
||||
);
|
||||
|
||||
// Draw quarter markers (6am, 12pm, 6pm positions conceptually)
|
||||
final markers = ['6:00', '12:00', '18:00'];
|
||||
final positions = [0.25, 0.5, 0.75];
|
||||
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
final angle = math.pi + (positions[i] * math.pi);
|
||||
final markerRadius = radius + 15;
|
||||
final x = center.dx + markerRadius * math.cos(angle);
|
||||
final y = center.dy + markerRadius * math.sin(angle);
|
||||
|
||||
textPainter.text = TextSpan(text: markers[i], style: markerStyle);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SunArcPainter oldDelegate) {
|
||||
return oldDelegate.sunTimes != sunTimes || oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'
|
||||
as api;
|
||||
|
||||
/// Weather widget displaying current conditions.
|
||||
///
|
||||
/// Currently uses mock data. Will be connected to weather API in future.
|
||||
/// Displays weather data from the Core API environment endpoint.
|
||||
class WeatherWidget extends StatelessWidget {
|
||||
const WeatherWidget({
|
||||
super.key,
|
||||
this.apiData,
|
||||
this.data,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// Weather data to display. Uses mock data if null.
|
||||
/// Weather data from API. Takes priority over legacy data.
|
||||
final api.WeatherData? apiData;
|
||||
|
||||
/// Legacy weather data to display. Uses mock data if null.
|
||||
final WeatherData? data;
|
||||
|
||||
/// Whether to use compact layout.
|
||||
@@ -19,7 +25,11 @@ class WeatherWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final weather = data ?? WeatherData.mock();
|
||||
|
||||
// Convert API data to local model, or use legacy data
|
||||
final weather = apiData != null
|
||||
? _fromApiData(apiData!)
|
||||
: (data ?? WeatherData.mock());
|
||||
|
||||
if (compact) {
|
||||
return _buildCompact(context, colorScheme, weather);
|
||||
@@ -206,6 +216,60 @@ class _DetailChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts API weather data to local WeatherData model.
|
||||
WeatherData _fromApiData(api.WeatherData data) {
|
||||
return WeatherData(
|
||||
location: data.location ?? 'Unknown',
|
||||
temperature: data.temperature ?? 0,
|
||||
condition: data.conditions ?? 'Unknown',
|
||||
icon: _getWeatherIcon(data.conditions, data.icon),
|
||||
humidity: data.humidity,
|
||||
windSpeed: data.windSpeed,
|
||||
feelsLike: data.feelsLike,
|
||||
iconColor: _getWeatherColor(data.conditions),
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps weather condition to icon.
|
||||
IconData _getWeatherIcon(String? conditions, String? iconCode) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Icons.wb_sunny;
|
||||
} else if (condition.contains('cloud') || condition.contains('overcast')) {
|
||||
return Icons.cloud;
|
||||
} else if (condition.contains('rain') || condition.contains('drizzle')) {
|
||||
return Icons.grain;
|
||||
} else if (condition.contains('storm') || condition.contains('thunder')) {
|
||||
return Icons.thunderstorm;
|
||||
} else if (condition.contains('snow') || condition.contains('sleet')) {
|
||||
return Icons.ac_unit;
|
||||
} else if (condition.contains('fog') || condition.contains('mist')) {
|
||||
return Icons.blur_on;
|
||||
} else if (condition.contains('wind')) {
|
||||
return Icons.air;
|
||||
}
|
||||
return Icons.cloud;
|
||||
}
|
||||
|
||||
/// Maps weather condition to color.
|
||||
Color? _getWeatherColor(String? conditions) {
|
||||
final condition = conditions?.toLowerCase() ?? '';
|
||||
|
||||
if (condition.contains('clear') || condition.contains('sunny')) {
|
||||
return Colors.amber;
|
||||
} else if (condition.contains('cloud')) {
|
||||
return Colors.blueGrey;
|
||||
} else if (condition.contains('rain')) {
|
||||
return Colors.blue;
|
||||
} else if (condition.contains('storm')) {
|
||||
return Colors.deepPurple;
|
||||
} else if (condition.contains('snow')) {
|
||||
return Colors.lightBlue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Temperature unit for weather display.
|
||||
enum TemperatureUnit {
|
||||
celsius('C'),
|
||||
|
||||
+4
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.4.0+1
|
||||
version: 1.5.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -57,6 +57,9 @@ dependencies:
|
||||
crypto: ^3.0.3
|
||||
web: ^1.1.0
|
||||
|
||||
# Internationalization
|
||||
intl: ^0.19.0
|
||||
|
||||
# UI
|
||||
flex_color_scheme: ^8.1.0
|
||||
flutter_adaptive_scaffold: ^0.3.1
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/air_quality_widget.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/weather_widget.dart';
|
||||
|
||||
import '../../harness/screen_sizes.dart';
|
||||
import '../../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
// Set larger window size and suppress overflow errors
|
||||
Future<void> setLargeWindowSize(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 900);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
// Suppress overflow errors - not what we're testing
|
||||
final originalOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exceptionAsString().contains('overflowed')) {
|
||||
return;
|
||||
}
|
||||
originalOnError?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = originalOnError);
|
||||
}
|
||||
|
||||
group('Environment Widgets', () {
|
||||
testWidgets('displays Sun Position widget', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SunPositionWidget), findsOneWidget);
|
||||
expect(find.text('Sun Position'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Weather widget', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(WeatherWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Forecast widget', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ForecastWidget), findsOneWidget);
|
||||
expect(find.text('Forecast'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Air Quality widget when data available',
|
||||
(tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment(); // Has air quality data
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AirQualityWidget), findsOneWidget);
|
||||
expect(find.text('Air Quality'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides Air Quality widget when no data', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironmentNoAirQuality(); // No air quality data
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Air Quality widget should not be present
|
||||
expect(find.byType(AirQualityWidget), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('calls environment API on mount', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
harness.verifyApiCalled('GET', '/tools/environment');
|
||||
});
|
||||
|
||||
testWidgets('displays error state when environment fails', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/tools/environment',
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show environment error message or error indicator
|
||||
expect(
|
||||
find.text('Failed to load environment data').evaluate().isNotEmpty ||
|
||||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty,
|
||||
isTrue,
|
||||
reason: 'Should display environment error',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('displays sunrise and sunset times', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display sunrise/sunset labels
|
||||
expect(find.text('Sunrise'), findsOneWidget);
|
||||
expect(find.text('Sunset'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays daylight duration', (tester) async {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display daylight label
|
||||
expect(find.text('Daylight'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('Environment Widgets Responsive', () {
|
||||
testWidgets('renders environment widgets at desktop size', (tester) async {
|
||||
configureScreenSize(tester, ScreenSizes.desktopLarge);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SunPositionWidget), findsOneWidget);
|
||||
expect(find.byType(WeatherWidget), findsOneWidget);
|
||||
expect(find.byType(ForecastWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders environment widgets at tablet size', (tester) async {
|
||||
configureScreenSize(tester, ScreenSizes.tabletLandscape);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SunPositionWidget), findsOneWidget);
|
||||
expect(find.byType(WeatherWidget), findsOneWidget);
|
||||
expect(find.byType(ForecastWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders environment widgets at mobile size', (tester) async {
|
||||
configureScreenSize(tester, ScreenSizes.mobile);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Page should render without crashing at mobile size
|
||||
// Widgets may be below the fold due to scrolling
|
||||
expect(find.byType(FrontHallPage), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -47,6 +48,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -60,6 +62,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -75,6 +78,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -88,6 +92,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -101,6 +106,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -114,6 +120,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -127,6 +134,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pump();
|
||||
@@ -142,6 +150,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -155,6 +164,7 @@ void main() {
|
||||
await setLargeWindowSize(tester);
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenEnvironment();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/tools/system/stats',
|
||||
@@ -179,6 +189,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -191,6 +202,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -204,6 +216,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks([]); // Empty list triggers fallback
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -219,6 +232,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks(); // Fixtures have Infrastructure, Development, Home
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -235,6 +249,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -248,6 +263,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -261,6 +277,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -273,6 +290,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -286,6 +304,7 @@ void main() {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenQuickLinks();
|
||||
harness.givenSystemStats();
|
||||
harness.givenEnvironment();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -318,4 +318,93 @@ class Fixtures {
|
||||
|
||||
static Map<String, dynamic> quickLink(int index) =>
|
||||
Map<String, dynamic>.from(quickLinks[index]);
|
||||
|
||||
// ============================================================
|
||||
// Environment Data
|
||||
// ============================================================
|
||||
|
||||
static const environment = {
|
||||
'weather': {
|
||||
'temperature': 8.5,
|
||||
'feels_like': 5.0,
|
||||
'conditions': 'Partly Cloudy',
|
||||
'humidity': 72,
|
||||
'wind_speed': 18.5,
|
||||
'wind_direction': 'SW',
|
||||
'location': 'Rotterdam, NL',
|
||||
'icon': '03d',
|
||||
},
|
||||
'forecast': [
|
||||
{
|
||||
'date': '2026-01-06',
|
||||
'high': 10.0,
|
||||
'low': 4.0,
|
||||
'conditions': 'Cloudy',
|
||||
'precipitation_chance': 20,
|
||||
},
|
||||
{
|
||||
'date': '2026-01-07',
|
||||
'high': 12.0,
|
||||
'low': 5.0,
|
||||
'conditions': 'Partly Cloudy',
|
||||
'precipitation_chance': 10,
|
||||
},
|
||||
{
|
||||
'date': '2026-01-08',
|
||||
'high': 9.0,
|
||||
'low': 3.0,
|
||||
'conditions': 'Rain',
|
||||
'precipitation_chance': 80,
|
||||
},
|
||||
{
|
||||
'date': '2026-01-09',
|
||||
'high': 11.0,
|
||||
'low': 6.0,
|
||||
'conditions': 'Sunny',
|
||||
'precipitation_chance': 0,
|
||||
},
|
||||
],
|
||||
'sun_times': {
|
||||
'sunrise': '2026-01-06T08:45:00Z',
|
||||
'sunset': '2026-01-06T16:50:00Z',
|
||||
'daylight_minutes': 485,
|
||||
'solar_noon': '2026-01-06T12:47:30Z',
|
||||
},
|
||||
'air_quality': {
|
||||
'aqi': 42,
|
||||
'quality': 'Good',
|
||||
'pm25': 8.5,
|
||||
'pm10': 15.0,
|
||||
'o3': 32.0,
|
||||
},
|
||||
'updated_at': '2026-01-06T10:30:00Z',
|
||||
'user': 'default',
|
||||
};
|
||||
|
||||
/// Environment data without air quality (for conditional rendering test).
|
||||
static const environmentNoAirQuality = {
|
||||
'weather': {
|
||||
'temperature': 8.5,
|
||||
'conditions': 'Partly Cloudy',
|
||||
'humidity': 72,
|
||||
'wind_speed': 18.5,
|
||||
'location': 'Rotterdam, NL',
|
||||
},
|
||||
'forecast': [
|
||||
{
|
||||
'date': '2026-01-06',
|
||||
'high': 10.0,
|
||||
'low': 4.0,
|
||||
'conditions': 'Cloudy',
|
||||
},
|
||||
],
|
||||
'sun_times': {
|
||||
'sunrise': '2026-01-06T08:45:00Z',
|
||||
'sunset': '2026-01-06T16:50:00Z',
|
||||
'daylight_minutes': 485,
|
||||
},
|
||||
'air_quality': null,
|
||||
'updated_at': '2026-01-06T10:30:00Z',
|
||||
'user': 'default',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,6 +172,16 @@ class TestHarness {
|
||||
api.whenGet('/tools/system/stats', stats ?? Fixtures.systemStats);
|
||||
}
|
||||
|
||||
/// Set up mock environment response.
|
||||
void givenEnvironment([Map<String, dynamic>? environment]) {
|
||||
api.whenGet('/tools/environment', environment ?? Fixtures.environment);
|
||||
}
|
||||
|
||||
/// Set up mock environment response without air quality.
|
||||
void givenEnvironmentNoAirQuality() {
|
||||
api.whenGet('/tools/environment', Fixtures.environmentNoAirQuality);
|
||||
}
|
||||
|
||||
/// Set up theme mode.
|
||||
void givenThemeMode(ThemeMode mode) {
|
||||
_themeMode = mode;
|
||||
|
||||
Reference in New Issue
Block a user