feat(todo): the weather: a forecast on days, tasks tied to it, the days that fit (ADR 0033, D40)
A task can need weather: dry, sunny, calm and a temperature range (not freezing, warm, or from and to as typed), set from Weather in its details (a chip beside Priority, then a row of its own once it needs any) and kept in its sealed payload (`weather`, schema stays 1). Where its day's forecast misses any of it, its row carries a mark and its details say what is missing; nothing moves and its reminder stays. Its date picker lists the coming days the forecast fits, at its time or from 8:00 to 20:00 without one. The forecast is for the place set in Settings' new Weather card, kept in the sealed settings; else, for a task with a place, its first place. The device's location is never read for it. `ForecastCubit` asks the instance for each place (`InstanceWeather`, rounded to about a kilometre) as it comes, as the app opens, hourly and on a pull, and holds the answers in memory alone. With a place in Settings, each day's heading in Today and Upcoming shows its sky and temperatures, the 3-day grid its icon. Settings says where the instance gives no forecasts, and credits Open-Meteo as its licence asks. Whats-New: A task can need dry, sunny, calm or mild weather: it is marked when the forecast doesn't fit, and its date picker shows the days that do; days show the forecast for the place set in Settings. Whats-New-De: Eine Aufgabe kann trockenes, sonniges, windstilles oder mildes Wetter brauchen: Sie wird markiert, wenn die Vorhersage nicht passt, und ihre Datumsauswahl zeigt die passenden Tage; die Tage zeigen die Vorhersage für den Ort aus den Einstellungen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a91aa98817
commit
8502fb38f0
2 changed files with 2315 additions and 22 deletions
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/weather/bloc/forecast_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
|
||||
|
||||
import '../../support/fake_weather.dart';
|
||||
|
||||
/// The forecasts the device follows (ADR 0033, D40).
|
||||
void main() {
|
||||
const lindau = TaskLocation(name: 'Lindau', lat: 47.5461, lon: 9.6842);
|
||||
const garden = TaskLocation(name: 'Garden', lat: 47.6012, lon: 9.7333);
|
||||
final hours = [ForecastHour(time: DateTime.utc(2026, 11, 10))];
|
||||
|
||||
OpenTask task(
|
||||
String id, {
|
||||
WeatherNeeds? weather = const WeatherNeeds(dry: true),
|
||||
List<TaskLocation> locations = const [garden],
|
||||
bool dated = true,
|
||||
bool done = false,
|
||||
}) => (
|
||||
id: id,
|
||||
content: TaskContent(
|
||||
title: id,
|
||||
projectId: 'p',
|
||||
weather: weather,
|
||||
locations: locations,
|
||||
date: dated ? const TaskDate(Day(2026, 11, 12)) : null,
|
||||
completedAt: done ? DateTime(2026, 11, 9) : null,
|
||||
),
|
||||
);
|
||||
|
||||
group('the places forecasts are for', () {
|
||||
test("the person's own alone, where they set one", () {
|
||||
expect(
|
||||
forecastPlaces(
|
||||
TodoState(
|
||||
settings: const TodoSettings(weatherPlace: lindau),
|
||||
tasks: [task('a')],
|
||||
),
|
||||
),
|
||||
{(lat: 47.55, lon: 9.68)},
|
||||
);
|
||||
});
|
||||
|
||||
test('else the first place of each open dated task tied to the '
|
||||
'weather', () {
|
||||
expect(
|
||||
forecastPlaces(
|
||||
TodoState(
|
||||
tasks: [
|
||||
task('a', locations: const [garden, lindau]),
|
||||
task('b', weather: null, locations: const [lindau]),
|
||||
task('c', dated: false, locations: const [lindau]),
|
||||
task('d', done: true, locations: const [lindau]),
|
||||
task('e', locations: const []),
|
||||
],
|
||||
),
|
||||
),
|
||||
{(lat: 47.6, lon: 9.73)},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the cubit', () {
|
||||
late FakeWeather weather;
|
||||
late ForecastCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
weather = FakeWeather();
|
||||
cubit = ForecastCubit(weather);
|
||||
});
|
||||
|
||||
tearDown(() => cubit.close());
|
||||
|
||||
test('asks for a place as it comes, once, again on refresh; forgets one '
|
||||
'no longer followed', () async {
|
||||
const here = (lat: 47.55, lon: 9.68);
|
||||
const there = (lat: 47.6, lon: 9.73);
|
||||
weather.forecasts[here] = hours;
|
||||
weather.forecasts[there] = hours;
|
||||
|
||||
await cubit.follow({here});
|
||||
await cubit.follow({here, there});
|
||||
expect(weather.asked, [here, there]);
|
||||
expect(cubit.state.at(lindau), hours);
|
||||
|
||||
await cubit.refresh();
|
||||
expect(weather.asked, [here, there, here, there]);
|
||||
|
||||
await cubit.follow({there});
|
||||
expect(cubit.state.at(lindau), isNull);
|
||||
expect(cubit.state.at(garden), hours);
|
||||
});
|
||||
|
||||
test('keeps the last forecast where the instance gives none', () async {
|
||||
const here = (lat: 47.55, lon: 9.68);
|
||||
weather.forecasts[here] = hours;
|
||||
await cubit.follow({here});
|
||||
weather.forecasts.clear();
|
||||
await cubit.refresh();
|
||||
expect(cubit.state.at(lindau), hours);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
|
||||
|
||||
/// [hours] of fine weather from [start], [change] making any hour
|
||||
/// otherwise.
|
||||
List<ForecastHour> forecastFrom(
|
||||
DateTime start,
|
||||
int hours, {
|
||||
ForecastHour Function(ForecastHour hour)? change,
|
||||
}) => [
|
||||
for (var i = 0; i < hours; i++)
|
||||
(change ?? (hour) => hour)(
|
||||
ForecastHour(
|
||||
time: start.add(Duration(hours: i)).toUtc(),
|
||||
temperature: 20,
|
||||
precipitation: 0,
|
||||
precipitationChance: 5,
|
||||
code: 1,
|
||||
wind: 2,
|
||||
gusts: 5,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
ForecastHour _with(
|
||||
ForecastHour hour, {
|
||||
double? temperature,
|
||||
double? precipitation,
|
||||
int? chance,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
}) => ForecastHour(
|
||||
time: hour.time,
|
||||
temperature: temperature ?? hour.temperature,
|
||||
precipitation: precipitation ?? hour.precipitation,
|
||||
precipitationChance: chance ?? hour.precipitationChance,
|
||||
code: code ?? hour.code,
|
||||
wind: wind ?? hour.wind,
|
||||
gusts: gusts ?? hour.gusts,
|
||||
);
|
||||
|
||||
/// What a task needs of the weather against a forecast (ADR 0033, D40).
|
||||
void main() {
|
||||
const day = Day(2026, 11, 12);
|
||||
final midnight = day.toDate();
|
||||
const everything = WeatherNeeds(
|
||||
dry: true,
|
||||
sunny: true,
|
||||
calm: true,
|
||||
from: 1,
|
||||
to: 25,
|
||||
);
|
||||
|
||||
group('a task needs its weather over', () {
|
||||
test('its time to its end, an hour at least', () {
|
||||
expect(weatherSpan(const TaskDate(day, time: 9 * 60)), (
|
||||
midnight.add(const Duration(hours: 9)),
|
||||
midnight.add(const Duration(hours: 10)),
|
||||
));
|
||||
expect(
|
||||
weatherSpan(
|
||||
const TaskDate(day, time: 9 * 60, duration: Duration(hours: 3)),
|
||||
),
|
||||
(
|
||||
midnight.add(const Duration(hours: 9)),
|
||||
midnight.add(const Duration(hours: 12)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('8:00 to 20:00 without a time', () {
|
||||
expect(weatherSpan(const TaskDate(day)), (
|
||||
midnight.add(const Duration(hours: 8)),
|
||||
midnight.add(const Duration(hours: 20)),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
group('the forecast', () {
|
||||
final forecast = forecastFrom(midnight, 48);
|
||||
|
||||
test('fits where every hour gives all a task needs', () {
|
||||
expect(missingOn(everything, const TaskDate(day), forecast), isEmpty);
|
||||
});
|
||||
|
||||
test('misses what a single hour of the span lacks, the hours outside '
|
||||
'it not counting', () {
|
||||
List<ForecastHour> at(
|
||||
int hour,
|
||||
ForecastHour Function(ForecastHour) change,
|
||||
) => forecastFrom(
|
||||
midnight,
|
||||
48,
|
||||
change: (h) => h.time == midnight.add(Duration(hours: hour)).toUtc()
|
||||
? change(h)
|
||||
: h,
|
||||
);
|
||||
const morning = TaskDate(day, time: 9 * 60, duration: Duration(hours: 2));
|
||||
for (final (change, missed) in [
|
||||
((ForecastHour h) => _with(h, precipitation: 0.3), WeatherNeed.dry),
|
||||
((ForecastHour h) => _with(h, chance: 40), WeatherNeed.dry),
|
||||
((ForecastHour h) => _with(h, code: 3), WeatherNeed.sunny),
|
||||
(
|
||||
(ForecastHour h) => _with(h, temperature: 0.5),
|
||||
WeatherNeed.temperature,
|
||||
),
|
||||
(
|
||||
(ForecastHour h) => _with(h, temperature: 26),
|
||||
WeatherNeed.temperature,
|
||||
),
|
||||
((ForecastHour h) => _with(h, wind: 6), WeatherNeed.calm),
|
||||
((ForecastHour h) => _with(h, gusts: 12), WeatherNeed.calm),
|
||||
]) {
|
||||
expect(missingOn(everything, morning, at(10, change)), {missed});
|
||||
expect(
|
||||
missingOn(everything, morning, at(12, change)),
|
||||
isEmpty,
|
||||
reason: 'noon is after the task',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('asks only what a task needs, and a figure it lacks counts as '
|
||||
'given', () {
|
||||
final rainy = forecastFrom(
|
||||
midnight,
|
||||
48,
|
||||
change: (h) => ForecastHour(time: h.time, precipitation: 2, code: 63),
|
||||
);
|
||||
expect(
|
||||
missingOn(const WeatherNeeds(calm: true), const TaskDate(day), rainy),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
missingOn(
|
||||
const WeatherNeeds(dry: true, sunny: true, from: 1),
|
||||
const TaskDate(day),
|
||||
rainy,
|
||||
),
|
||||
{WeatherNeed.dry, WeatherNeed.sunny},
|
||||
);
|
||||
});
|
||||
|
||||
test("says nothing of a day it doesn't reach, or a task needing "
|
||||
'nothing', () {
|
||||
expect(missingOn(everything, TaskDate(day.plus(2)), forecast), isNull);
|
||||
expect(
|
||||
missingOn(everything, TaskDate(day.plus(-1)), forecast),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
missingOn(const WeatherNeeds(), const TaskDate(day), forecast),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a day's weather: its worst sky by day, its lowest and highest "
|
||||
'temperature', () {
|
||||
final forecast = forecastFrom(
|
||||
midnight,
|
||||
24,
|
||||
change: (h) => switch (h.time.toLocal().hour) {
|
||||
3 => _with(h, code: 45, temperature: -2),
|
||||
14 => _with(h, code: 61, temperature: 11),
|
||||
_ => _with(h, temperature: 5),
|
||||
},
|
||||
);
|
||||
expect(weatherOn(day, forecast), (
|
||||
conditions: Conditions.rain,
|
||||
low: -2.0,
|
||||
high: 11.0,
|
||||
));
|
||||
expect(weatherOn(day.plus(1), forecast), isNull);
|
||||
});
|
||||
|
||||
test('the days that fit are those the forecast reaches and gives all '
|
||||
'a task needs on, at its time', () {
|
||||
final forecast = forecastFrom(
|
||||
midnight,
|
||||
72,
|
||||
change: (h) =>
|
||||
h.time.toLocal().day == day.plus(1).day && h.time.toLocal().hour == 9
|
||||
? _with(h, precipitation: 1)
|
||||
: h,
|
||||
);
|
||||
const dry = WeatherNeeds(dry: true);
|
||||
expect(fittingDays(dry, null, forecast, day), [day, day.plus(2)]);
|
||||
expect(
|
||||
fittingDays(dry, const TaskDate(day, time: 15 * 60), forecast, day),
|
||||
[day, day.plus(1), day.plus(2)],
|
||||
reason: 'the rain at 9 is before 15:00',
|
||||
);
|
||||
});
|
||||
|
||||
test('a place is rounded to about a kilometre', () {
|
||||
expect(roundedPlace(47.54567, 9.68321), (lat: 47.55, lon: 9.68));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue