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
6 changed files with 2315 additions and 22 deletions

View file

@ -44,12 +44,14 @@ import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.da
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.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/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/title_tokens.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
@ -63,6 +65,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_calendar_feeds.dart';
import '../support/fake_weather.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_location_gateway.dart';
import '../support/memory_reminder_gateway.dart';
@ -88,6 +91,7 @@ void main() {
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late FakeCalendarFeeds calendarFeeds;
late FakeWeather weather;
late MemoryLocationGateway locationGateway;
late FakeSpeechInput speech;
@ -121,6 +125,7 @@ void main() {
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
calendarFeeds = FakeCalendarFeeds();
weather = FakeWeather();
locationGateway = MemoryLocationGateway();
speech = FakeSpeechInput();
store = MemoryInstanceStore();
@ -200,6 +205,7 @@ void main() {
drawAvatar: (picture) async => picture,
),
speech: speech,
weather: weather,
appVersion: (version: '0.1.0', build: 1),
sourceCodeUrl: () async =>
'https://code.halkyon.app/project-halkyon/halkyon',
@ -4585,6 +4591,180 @@ void main() {
expect(yard?.view, const ProjectView(layout: ProjectLayout.board));
});
group("the weather (D40, the developer's decisions of 10 Nov 2026)", () {
const garden = TaskLocation(name: 'Garden', lat: 47.6012, lon: 9.7333);
const lindau = TaskLocation(name: 'Lindau', lat: 47.5461, lon: 9.6842);
/// 17 days of fine weather from today's midnight, rain over all of
/// tomorrow, at [place].
void forecastAt(TaskLocation place) {
final today = Day.of(DateTime.now());
weather.forecasts[roundedPlace(place.lat, place.lon)] = [
for (var hour = 0; hour < 17 * 24; hour++)
if (today.toDate().add(Duration(hours: hour)) case final time)
ForecastHour(
time: time.toUtc(),
temperature: Day.of(time) == today.plus(1) ? 5 : 12,
precipitation: Day.of(time) == today.plus(1) ? 2 : 0,
precipitationChance: 10,
code: Day.of(time) == today.plus(1) ? 63 : 1,
wind: 2,
gusts: 4,
),
];
}
/// "Lawn" in the Inbox tomorrow at [locations], needing [needs].
Future<void> seedLawn(
WidgetTester tester, {
WeatherNeeds? needs,
List<TaskLocation> locations = const [garden],
}) async {
await tester.runAsync(() async {
final inbox = await ProjectRepository(
projectRows,
keys,
).ensureInbox();
await TaskRepository(taskRows, keys).add(
TaskContent(
title: 'Lawn',
projectId: inbox,
date: TaskDate(Day.of(DateTime.now()).plus(1)),
locations: locations,
weather: needs,
),
);
});
projectRows.bringRemoteChanges();
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets("a task whose day's forecast misses what it needs is "
'marked, its details saying why, judged at its own place where '
'the person set none', (tester) async {
forecastAt(garden);
await start(tester, usedBefore: true);
await seedLawn(tester, needs: const WeatherNeeds(dry: true));
expect(weather.asked, [roundedPlace(garden.lat, garden.lon)]);
await goTab(tester, 'Inbox');
expect(find.byKey(TaskTile.weatherMarkKey), findsOneWidget);
await tester.tap(find.text('Lawn'));
await tester.pumpAndSettle();
expect(find.text("The forecast doesn't fit: rain"), findsOneWidget);
expect(find.text('Dry'), findsOneWidget);
});
testWidgets('what a task needs is set in its details, and its date '
'picker offers the days that fit', (tester) async {
forecastAt(garden);
await start(tester, usedBefore: true);
await seedLawn(tester);
await goTab(tester, 'Inbox');
expect(find.byKey(TaskTile.weatherMarkKey), findsNothing);
await tester.tap(find.text('Lawn'));
await tester.pumpAndSettle();
final row = find.text('Weather');
await tester.ensureVisible(row);
await tester.tap(row);
await tester.pumpAndSettle();
await tester.tap(find.text('Dry'));
await tester.tap(find.text('Not freezing'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(
() => TaskRepository(taskRows, keys).all(),
);
expect(
stored!.single.content?.weather,
const WeatherNeeds(dry: true, from: WeatherNeeds.notFreezing),
);
expect(find.text('Dry · from 1 °C'), findsOneWidget);
expect(find.text("The forecast doesn't fit: rain"), findsOneWidget);
// The sheet's date, the row behind it saying it too.
final date = find.text('Tomorrow').last;
await tester.ensureVisible(date);
await tester.pumpAndSettle();
await tester.tap(date);
await tester.pumpAndSettle();
final fitting = find.byKey(fittingDaysKey);
expect(fitting, findsOneWidget);
expect(
find.descendant(of: fitting, matching: find.text('Today')),
findsOneWidget,
);
expect(
find.descendant(of: fitting, matching: find.text('Tomorrow')),
findsNothing,
);
});
testWidgets("the person's own place, set in Settings, is the one "
'asked for and shows on days; their tasks are judged by it', (
tester,
) async {
forecastAt(lindau);
await start(tester, usedBefore: true);
await tester.runAsync(
() => SettingsRepository(
settingsRows,
keys,
).write(const TodoSettings(weatherPlace: lindau)),
);
settingsRows.bringRemoteChanges();
await tester.pumpAndSettle();
await seedLawn(tester, needs: const WeatherNeeds(dry: true));
expect(weather.asked, [roundedPlace(lindau.lat, lindau.lon)]);
await goTab(tester, 'Upcoming');
expect(find.text('5–5 °C'), findsOneWidget, reason: 'tomorrow');
expect(find.byKey(TaskTile.weatherMarkKey), findsOneWidget);
await openSettings(tester);
final place = find.text('Lindau');
await tester.ensureVisible(place);
expect(place, findsOneWidget);
await tester.tap(find.byTooltip('Remove the place'));
await tester.pumpAndSettle();
final settings = await tester.runAsync(
() => SettingsRepository(settingsRows, keys).read(),
);
expect(settings!.weatherPlace, isNull);
expect(find.text("None: each task's own place"), findsOneWidget);
});
testWidgets('without a place of the person or the task, nothing is '
'asked or marked', (tester) async {
forecastAt(garden);
await start(tester, usedBefore: true);
await seedLawn(
tester,
needs: const WeatherNeeds(dry: true),
locations: const [],
);
await goTab(tester, 'Inbox');
expect(weather.asked, isEmpty);
expect(find.byKey(TaskTile.weatherMarkKey), findsNothing);
});
testWidgets('Settings says where the instance gives no forecasts', (
tester,
) async {
weather.isOffered = false;
await start(tester, usedBefore: true);
await openSettings(tester);
final off = find.text('This instance gives no forecasts.');
await tester.ensureVisible(off);
expect(off, findsOneWidget);
expect(find.text('Weather data by Open-Meteo.com'), findsOneWidget);
});
});
group("a project's linked calendar (D39, the developer's decisions of "
'10 Nov 2026)', () {
ProjectRepository projects() => ProjectRepository(projectRows, keys);

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

View file

@ -0,0 +1,21 @@
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
/// Forecasts as the instance would give them (ADR 0033, D40): the hours
/// [forecasts] holds for a place, none for one it doesn't (refused); and
/// each place asked for.
class FakeWeather() implements WeatherGateway {
final forecasts = <LatLon, List<ForecastHour>>{};
final asked = <LatLon>[];
bool isOffered = true;
@override
Future<bool> offered() async => isOffered;
@override
Future<List<ForecastHour>> forecast(LatLon place) async {
asked.add(place);
return forecasts[place] ?? (throw const WeatherUnavailable());
}
}