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:
Tizian 「ティツィアーン」 2026-11-10 14:51:52 +01:00
commit 8502fb38f0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 2315 additions and 22 deletions

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
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/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
@ -25,6 +26,38 @@ void main() {
);
});
test('the place of the forecast (D40) reads back as it was set; one that '
"can't be read is none", () {
const settings = TodoSettings(
weatherPlace: TaskLocation(
name: 'Lindau',
lat: 47.546,
lon: 9.684,
address: 'Bavaria',
),
);
expect(written(settings)['weather'], {
'place': {
'name': 'Lindau',
'lat': 47.546,
'lon': 9.684,
'address': 'Bavaria',
},
});
expect(decodeSettings(encodeSettings(settings)), settings);
expect(
decodeSettings(
payload({
'schemaVersion': 1,
'weather': {
'place': {'name': 'Nowhere', 'lat': 120, 'lon': 0},
},
}),
),
const TodoSettings(),
);
});
test('goals, days off and vacations read back as they were set', () {
const settings = TodoSettings(
goals: GoalSettings(

View file

@ -11,6 +11,7 @@ import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/weather_needs.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// Tasks', sections' and labels' payloads (ADR 0033, D6): UTF-8 JSON with
@ -241,6 +242,52 @@ void main() {
Priority.none,
);
});
test('the weather it needs (D40) reads back as written, only what it '
'needs; needing nothing is none', () {
const needs = TaskContent(
title: 'Hang the washing out',
projectId: 'p',
weather: WeatherNeeds(dry: true, calm: true, from: 1),
);
expect(written(encodeTask(needs))['weather'], {
'dry': true,
'calm': true,
'from': 1.0,
});
expect(decodeTask(encodeTask(needs)), needs);
expect(
written(
encodeTask(needs.copyWith(weather: () => const WeatherNeeds())),
),
isNot(contains('weather')),
);
expect(
decodeTask(
json({
'schemaVersion': 1,
'title': 'A',
'projectId': 'p',
'weather': <String, Object?>{},
}),
)?.weather,
isNull,
);
});
test('weather whose temperature is no number is not read at all', () {
expect(
decodeTask(
json({
'schemaVersion': 1,
'title': 'A',
'projectId': 'p',
'weather': {'from': 'warm'},
}),
),
isNull,
);
});
});
group('a day', () {

View file

@ -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);
});
});
}

View file

@ -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));
});
}