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
1 changed files with 2315 additions and 22 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue