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

File diff suppressed because one or more lines are too long

View file

@ -409,5 +409,33 @@
"projectCalendarAddress": "Kalenderadresse (iCal)",
"projectCalendarHelp": "Seine Termine stehen neben den Aufgaben dieses Projekts, nur zum Lesen. Jedes deiner Geräte ruft ihn selbst ab.",
"projectCalendarInvalid": "Eine Adresse, die mit https:// oder webcal:// beginnt",
"eventFromCalendar": "Aus dem Kalender von {project}"
"eventFromCalendar": "Aus dem Kalender von {project}",
"weatherTitle": "Wetter",
"weatherNeedDry": "Trocken",
"weatherNeedSunny": "Sonnig",
"weatherNeedCalm": "Windstill",
"weatherTemperature": "Temperatur",
"weatherAnyTemperature": "Egal",
"weatherNotFreezing": "Kein Frost",
"weatherWarm": "Warm",
"weatherOwnRange": "Von … bis …",
"weatherFromLabel": "Ab",
"weatherToLabel": "Bis",
"weatherFrom": "ab {degrees} °C",
"weatherUpTo": "bis {degrees} °C",
"weatherBetween": "{low}–{high} °C",
"weatherDone": "Fertig",
"weatherFits": "Die Vorhersage passt",
"weatherDoesNotFit": "Die Vorhersage passt nicht: {missed}",
"weatherMiss": "{need, select, dry{Regen} sunny{Wolken} temperature{Temperatur} calm{Wind} other{Wetter}}",
"weatherConditions": "{conditions, select, clear{Klar} partlyCloudy{Teils bewölkt} cloudy{Bedeckt} fog{Nebel} drizzle{Nieselregen} rain{Regen} freezingRain{Gefrierender Regen} snow{Schnee} rainShowers{Schauer} snowShowers{Schneeschauer} thunderstorm{Gewitter} other{Wetter}}",
"weatherFittingDays": "Tage mit passendem Wetter",
"weatherNoFittingDay": "Kein Tag der Vorhersage passt",
"weatherSettings": "Wetter",
"weatherPlace": "Ort der Vorhersage",
"weatherPlaceNone": "Keiner: der eigene Ort jeder Aufgabe",
"weatherPlaceHelp": "Die Tage zeigen die Vorhersage für diesen Ort, und Aufgaben, die vom Wetter abhängen, richten sich nach ihr. Der Standort deines Geräts wird dafür nie verwendet.",
"weatherPlaceRemove": "Ort entfernen",
"weatherOff": "Diese Instanz liefert keine Vorhersagen.",
"weatherBy": "Wetterdaten von Open-Meteo.com"
}

View file

@ -1287,5 +1287,150 @@
"type": "String"
}
}
},
"weatherTitle": "Weather",
"@weatherTitle": {
"description": "A task's weather it needs, in its details and the sheet choosing it (ADR 0033, D40)."
},
"weatherNeedDry": "Dry",
"@weatherNeedDry": {
"description": "A task needs no rain or snow over its hours (D40)."
},
"weatherNeedSunny": "Sunny",
"@weatherNeedSunny": {
"description": "A task needs a clear or partly cloudy sky (D40)."
},
"weatherNeedCalm": "Calm",
"@weatherNeedCalm": {
"description": "A task needs little wind (D40)."
},
"weatherTemperature": "Temperature",
"@weatherTemperature": {
"description": "The temperatures a task needs (D40)."
},
"weatherAnyTemperature": "Any",
"@weatherAnyTemperature": {
"description": "No temperature needed (D40)."
},
"weatherNotFreezing": "Not freezing",
"@weatherNotFreezing": {
"description": "From 1 °C on (D40)."
},
"weatherWarm": "Warm",
"@weatherWarm": {
"description": "From 18 °C on (D40)."
},
"weatherOwnRange": "From … to …",
"@weatherOwnRange": {
"description": "Temperatures typed (D40)."
},
"weatherFromLabel": "From",
"@weatherFromLabel": {
"description": "The lowest temperature typed, in °C (D40)."
},
"weatherToLabel": "To",
"@weatherToLabel": {
"description": "The highest temperature typed, in °C (D40)."
},
"weatherFrom": "from {degrees} °C",
"@weatherFrom": {
"description": "A task needs at least this temperature (D40).",
"placeholders": {
"degrees": {
"type": "String"
}
}
},
"weatherUpTo": "up to {degrees} °C",
"@weatherUpTo": {
"description": "A task needs at most this temperature (D40).",
"placeholders": {
"degrees": {
"type": "String"
}
}
},
"weatherBetween": "{low}–{high} °C",
"@weatherBetween": {
"description": "A range of temperatures (D40).",
"placeholders": {
"low": {
"type": "String"
},
"high": {
"type": "String"
}
}
},
"weatherDone": "Done",
"@weatherDone": {
"description": "Closes the sheet of a task's weather, keeping what was chosen (D40)."
},
"weatherFits": "The forecast fits",
"@weatherFits": {
"description": "Under a task's weather where its day's forecast gives what it needs (D40)."
},
"weatherDoesNotFit": "The forecast doesn't fit: {missed}",
"@weatherDoesNotFit": {
"description": "Under a task's weather, and its row's mark, where its day's forecast doesn't give what it needs (D40).",
"placeholders": {
"missed": {
"type": "String"
}
}
},
"weatherMiss": "{need, select, dry{rain} sunny{clouds} temperature{temperature} calm{wind} other{weather}}",
"@weatherMiss": {
"description": "What of the weather a task needs its forecast misses (D40).",
"placeholders": {
"need": {
"type": "String"
}
}
},
"weatherConditions": "{conditions, select, clear{Clear} partlyCloudy{Partly cloudy} cloudy{Overcast} fog{Fog} drizzle{Drizzle} rain{Rain} freezingRain{Freezing rain} snow{Snow} rainShowers{Showers} snowShowers{Snow showers} thunderstorm{Thunderstorm} other{Weather}}",
"@weatherConditions": {
"description": "A day's sky by its forecast, from Open-Meteo's weather code (D40).",
"placeholders": {
"conditions": {
"type": "String"
}
}
},
"weatherFittingDays": "Days the weather fits",
"@weatherFittingDays": {
"description": "The date picker's days whose forecast gives what the task needs (D40)."
},
"weatherNoFittingDay": "No day of the forecast fits",
"@weatherNoFittingDay": {
"description": "The date picker, where no day of the forecast fits (D40)."
},
"weatherSettings": "Weather",
"@weatherSettings": {
"description": "Settings' group of the forecast (D40)."
},
"weatherPlace": "Place of the forecast",
"@weatherPlace": {
"description": "Settings: the place days' forecasts and tasks' weather are for (D40)."
},
"weatherPlaceNone": "None: each task's own place",
"@weatherPlaceNone": {
"description": "Settings, where no place is set (D40)."
},
"weatherPlaceHelp": "Days show this place's forecast, and tasks tied to the weather are judged by it. Your device's location is never used for it.",
"@weatherPlaceHelp": {
"description": "Settings, below the place of the forecast (D40)."
},
"weatherPlaceRemove": "Remove the place",
"@weatherPlaceRemove": {
"description": "Settings: takes the place of the forecast away (D40)."
},
"weatherOff": "This instance gives no forecasts.",
"@weatherOff": {
"description": "Settings, where the instance's operator switched the weather off (D40)."
},
"weatherBy": "Weather data by Open-Meteo.com",
"@weatherBy": {
"description": "The credit Open-Meteo's licence asks for next to its data (D40)."
}
}

View file

@ -2559,6 +2559,174 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'From {project}\'s calendar'**
String eventFromCalendar(String project);
/// A task's weather it needs, in its details and the sheet choosing it (ADR 0033, D40).
///
/// In en, this message translates to:
/// **'Weather'**
String get weatherTitle;
/// A task needs no rain or snow over its hours (D40).
///
/// In en, this message translates to:
/// **'Dry'**
String get weatherNeedDry;
/// A task needs a clear or partly cloudy sky (D40).
///
/// In en, this message translates to:
/// **'Sunny'**
String get weatherNeedSunny;
/// A task needs little wind (D40).
///
/// In en, this message translates to:
/// **'Calm'**
String get weatherNeedCalm;
/// The temperatures a task needs (D40).
///
/// In en, this message translates to:
/// **'Temperature'**
String get weatherTemperature;
/// No temperature needed (D40).
///
/// In en, this message translates to:
/// **'Any'**
String get weatherAnyTemperature;
/// From 1 °C on (D40).
///
/// In en, this message translates to:
/// **'Not freezing'**
String get weatherNotFreezing;
/// From 18 °C on (D40).
///
/// In en, this message translates to:
/// **'Warm'**
String get weatherWarm;
/// Temperatures typed (D40).
///
/// In en, this message translates to:
/// **'From … to …'**
String get weatherOwnRange;
/// The lowest temperature typed, in °C (D40).
///
/// In en, this message translates to:
/// **'From'**
String get weatherFromLabel;
/// The highest temperature typed, in °C (D40).
///
/// In en, this message translates to:
/// **'To'**
String get weatherToLabel;
/// A task needs at least this temperature (D40).
///
/// In en, this message translates to:
/// **'from {degrees} °C'**
String weatherFrom(String degrees);
/// A task needs at most this temperature (D40).
///
/// In en, this message translates to:
/// **'up to {degrees} °C'**
String weatherUpTo(String degrees);
/// A range of temperatures (D40).
///
/// In en, this message translates to:
/// **'{low}–{high} °C'**
String weatherBetween(String low, String high);
/// Closes the sheet of a task's weather, keeping what was chosen (D40).
///
/// In en, this message translates to:
/// **'Done'**
String get weatherDone;
/// Under a task's weather where its day's forecast gives what it needs (D40).
///
/// In en, this message translates to:
/// **'The forecast fits'**
String get weatherFits;
/// Under a task's weather, and its row's mark, where its day's forecast doesn't give what it needs (D40).
///
/// In en, this message translates to:
/// **'The forecast doesn\'t fit: {missed}'**
String weatherDoesNotFit(String missed);
/// What of the weather a task needs its forecast misses (D40).
///
/// In en, this message translates to:
/// **'{need, select, dry{rain} sunny{clouds} temperature{temperature} calm{wind} other{weather}}'**
String weatherMiss(String need);
/// A day's sky by its forecast, from Open-Meteo's weather code (D40).
///
/// In en, this message translates to:
/// **'{conditions, select, clear{Clear} partlyCloudy{Partly cloudy} cloudy{Overcast} fog{Fog} drizzle{Drizzle} rain{Rain} freezingRain{Freezing rain} snow{Snow} rainShowers{Showers} snowShowers{Snow showers} thunderstorm{Thunderstorm} other{Weather}}'**
String weatherConditions(String conditions);
/// The date picker's days whose forecast gives what the task needs (D40).
///
/// In en, this message translates to:
/// **'Days the weather fits'**
String get weatherFittingDays;
/// The date picker, where no day of the forecast fits (D40).
///
/// In en, this message translates to:
/// **'No day of the forecast fits'**
String get weatherNoFittingDay;
/// Settings' group of the forecast (D40).
///
/// In en, this message translates to:
/// **'Weather'**
String get weatherSettings;
/// Settings: the place days' forecasts and tasks' weather are for (D40).
///
/// In en, this message translates to:
/// **'Place of the forecast'**
String get weatherPlace;
/// Settings, where no place is set (D40).
///
/// In en, this message translates to:
/// **'None: each task\'s own place'**
String get weatherPlaceNone;
/// Settings, below the place of the forecast (D40).
///
/// In en, this message translates to:
/// **'Days show this place\'s forecast, and tasks tied to the weather are judged by it. Your device\'s location is never used for it.'**
String get weatherPlaceHelp;
/// Settings: takes the place of the forecast away (D40).
///
/// In en, this message translates to:
/// **'Remove the place'**
String get weatherPlaceRemove;
/// Settings, where the instance's operator switched the weather off (D40).
///
/// In en, this message translates to:
/// **'This instance gives no forecasts.'**
String get weatherOff;
/// The credit Open-Meteo's licence asks for next to its data (D40).
///
/// In en, this message translates to:
/// **'Weather data by Open-Meteo.com'**
String get weatherBy;
}
class _AppLocalizationsDelegate

View file

@ -1608,4 +1608,128 @@ class AppLocalizationsDe extends AppLocalizations {
String eventFromCalendar(String project) {
return 'Aus dem Kalender von $project';
}
@override
String get weatherTitle => 'Wetter';
@override
String get weatherNeedDry => 'Trocken';
@override
String get weatherNeedSunny => 'Sonnig';
@override
String get weatherNeedCalm => 'Windstill';
@override
String get weatherTemperature => 'Temperatur';
@override
String get weatherAnyTemperature => 'Egal';
@override
String get weatherNotFreezing => 'Kein Frost';
@override
String get weatherWarm => 'Warm';
@override
String get weatherOwnRange => 'Von … bis …';
@override
String get weatherFromLabel => 'Ab';
@override
String get weatherToLabel => 'Bis';
@override
String weatherFrom(String degrees) {
return 'ab $degrees °C';
}
@override
String weatherUpTo(String degrees) {
return 'bis $degrees °C';
}
@override
String weatherBetween(String low, String high) {
return '$low–$high °C';
}
@override
String get weatherDone => 'Fertig';
@override
String get weatherFits => 'Die Vorhersage passt';
@override
String weatherDoesNotFit(String missed) {
return 'Die Vorhersage passt nicht: $missed';
}
@override
String weatherMiss(String need) {
String _temp0 = intl.Intl.selectLogic(
need,
{
'dry': 'Regen',
'sunny': 'Wolken',
'temperature': 'Temperatur',
'calm': 'Wind',
'other': 'Wetter',
},
);
return '$_temp0';
}
@override
String weatherConditions(String conditions) {
String _temp0 = intl.Intl.selectLogic(
conditions,
{
'clear': 'Klar',
'partlyCloudy': 'Teils bewölkt',
'cloudy': 'Bedeckt',
'fog': 'Nebel',
'drizzle': 'Nieselregen',
'rain': 'Regen',
'freezingRain': 'Gefrierender Regen',
'snow': 'Schnee',
'rainShowers': 'Schauer',
'snowShowers': 'Schneeschauer',
'thunderstorm': 'Gewitter',
'other': 'Wetter',
},
);
return '$_temp0';
}
@override
String get weatherFittingDays => 'Tage mit passendem Wetter';
@override
String get weatherNoFittingDay => 'Kein Tag der Vorhersage passt';
@override
String get weatherSettings => 'Wetter';
@override
String get weatherPlace => 'Ort der Vorhersage';
@override
String get weatherPlaceNone => 'Keiner: der eigene Ort jeder Aufgabe';
@override
String get weatherPlaceHelp =>
'Die Tage zeigen die Vorhersage für diesen Ort, und Aufgaben, die vom Wetter abhängen, richten sich nach ihr. Der Standort deines Geräts wird dafür nie verwendet.';
@override
String get weatherPlaceRemove => 'Ort entfernen';
@override
String get weatherOff => 'Diese Instanz liefert keine Vorhersagen.';
@override
String get weatherBy => 'Wetterdaten von Open-Meteo.com';
}

View file

@ -1605,6 +1605,130 @@ class AppLocalizationsEn extends AppLocalizations {
String eventFromCalendar(String project) {
return 'From $project\'s calendar';
}
@override
String get weatherTitle => 'Weather';
@override
String get weatherNeedDry => 'Dry';
@override
String get weatherNeedSunny => 'Sunny';
@override
String get weatherNeedCalm => 'Calm';
@override
String get weatherTemperature => 'Temperature';
@override
String get weatherAnyTemperature => 'Any';
@override
String get weatherNotFreezing => 'Not freezing';
@override
String get weatherWarm => 'Warm';
@override
String get weatherOwnRange => 'From … to …';
@override
String get weatherFromLabel => 'From';
@override
String get weatherToLabel => 'To';
@override
String weatherFrom(String degrees) {
return 'from $degrees °C';
}
@override
String weatherUpTo(String degrees) {
return 'up to $degrees °C';
}
@override
String weatherBetween(String low, String high) {
return '$low–$high °C';
}
@override
String get weatherDone => 'Done';
@override
String get weatherFits => 'The forecast fits';
@override
String weatherDoesNotFit(String missed) {
return 'The forecast doesn\'t fit: $missed';
}
@override
String weatherMiss(String need) {
String _temp0 = intl.Intl.selectLogic(
need,
{
'dry': 'rain',
'sunny': 'clouds',
'temperature': 'temperature',
'calm': 'wind',
'other': 'weather',
},
);
return '$_temp0';
}
@override
String weatherConditions(String conditions) {
String _temp0 = intl.Intl.selectLogic(
conditions,
{
'clear': 'Clear',
'partlyCloudy': 'Partly cloudy',
'cloudy': 'Overcast',
'fog': 'Fog',
'drizzle': 'Drizzle',
'rain': 'Rain',
'freezingRain': 'Freezing rain',
'snow': 'Snow',
'rainShowers': 'Showers',
'snowShowers': 'Snow showers',
'thunderstorm': 'Thunderstorm',
'other': 'Weather',
},
);
return '$_temp0';
}
@override
String get weatherFittingDays => 'Days the weather fits';
@override
String get weatherNoFittingDay => 'No day of the forecast fits';
@override
String get weatherSettings => 'Weather';
@override
String get weatherPlace => 'Place of the forecast';
@override
String get weatherPlaceNone => 'None: each task\'s own place';
@override
String get weatherPlaceHelp =>
'Days show this place\'s forecast, and tasks tied to the weather are judged by it. Your device\'s location is never used for it.';
@override
String get weatherPlaceRemove => 'Remove the place';
@override
String get weatherOff => 'This instance gives no forecasts.';
@override
String get weatherBy => 'Weather data by Open-Meteo.com';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.da
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_scheduler.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/bloc/forecast_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -134,19 +135,41 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
)..start();
},
),
// The forecasts of the person's place or their tasks' (D40):
// asked for as the app opens and every hour while it is open.
BlocProvider(
create: (context) => ForecastCubit(
AppScope.of<TodoDependencies>(context).weather,
)..start(),
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
),
],
child: BlocListener<TodoCubit, TodoState>(
// A project linked to a calendar, or unlinked, on any device.
listenWhen: (before, now) =>
!listEquals(before.projects, now.projects),
listener: (context, state) => unawaited(
context.read<CalendarCubit>().follow(state.projects),
),
child: MultiBlocListener(
listeners: [
BlocListener<TodoCubit, TodoState>(
// A project linked to a calendar, or unlinked, on any
// device.
listenWhen: (before, now) =>
!listEquals(before.projects, now.projects),
listener: (context, state) => unawaited(
context.read<CalendarCubit>().follow(state.projects),
),
),
BlocListener<TodoCubit, TodoState>(
// The places forecasts are for: the person's own, or
// their tasks' tied to the weather; asked for as each
// comes (D40).
listener: (context, state) => unawaited(
context.read<ForecastCubit>().follow(
forecastPlaces(state),
),
),
),
],
child: TaskRevealScope(
notifier: _reveal,
child: ScreenColor(

View file

@ -34,6 +34,7 @@ import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_reposito
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
@ -221,6 +222,10 @@ class TodoDependencies({
/// placed by hand.
final PlaceSearchGateway? places,
/// Forecasts through the instance (ADR 0033, D40): none in tests that
/// don't say.
final WeatherGateway weather = const NoWeather(),
/// The tile layer of the location picker's map; none in widget tests,
/// which reach no network.
final MapTiles? mapTiles,
@ -324,6 +329,7 @@ class TodoDependencies({
places: _publicPhoton
? PublicPhotonGateway()
: InstancePlaceSearch.of(client),
weather: InstanceWeather(client),
mapTiles: vectorTiles,
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,

View file

@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// The schema of the settings' payload this app writes and reads.
@ -42,6 +44,8 @@ ByteData encodeSettings(TodoSettings settings) =>
],
'celebrated': ?settings.goals.celebrated?.toString(),
},
if (settings.weatherPlace case final place?)
'weather': {'place': encodeLocation(place)},
});
Map<String, Object?> _encodeUpcoming(UpcomingView view) => {
@ -62,9 +66,23 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
tasks: _decodeTasks(json['tasks']),
reminders: _decodeReminders(json['reminders']),
goals: _decodeGoals(json['goals']),
weatherPlace: _decodeWeatherPlace(json['weather']),
),
);
/// The place of the forecast (D40); none where it can't be read, as any
/// setting this version doesn't understand reads as at first.
TaskLocation? _decodeWeatherPlace(Object? json) {
if (json case {'place': final Object place}) {
try {
return decodeLocation(place);
} on FormatException {
return null;
}
}
return null;
}
GoalSettings _decodeGoals(Object? json) {
if (json is! Map<String, Object?>) {
return const GoalSettings();

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.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/task/model/task.dart';
@ -177,13 +178,16 @@ final class const GoalSettings({
/// The person's settings of Todo's (D4, D17): one sealed row of theirs,
/// synced to every device (`todo_settings`): [upcoming]'s and [today]'s
/// Displays, the settings of [tasks] and [reminders], and their [goals].
/// Displays, the settings of [tasks] and [reminders], their [goals], and
/// the place their forecast is for ([weatherPlace], D40; none: each task's
/// own).
final class const TodoSettings({
final UpcomingView upcoming = const UpcomingView(),
final TodayView today = const TodayView(),
final TaskSettings tasks = const TaskSettings(),
final ReminderSettings reminders = const ReminderSettings(),
final GoalSettings goals = const GoalSettings(),
final TaskLocation? weatherPlace,
}) with Equatable {
TodoSettings copyWith({
UpcomingView? upcoming,
@ -191,14 +195,23 @@ final class const TodoSettings({
TaskSettings? tasks,
ReminderSettings? reminders,
GoalSettings? goals,
TaskLocation? Function()? weatherPlace,
}) => TodoSettings(
upcoming: upcoming ?? this.upcoming,
today: today ?? this.today,
tasks: tasks ?? this.tasks,
reminders: reminders ?? this.reminders,
goals: goals ?? this.goals,
weatherPlace: weatherPlace == null ? this.weatherPlace : weatherPlace(),
);
@override
List<Object?> get props => [upcoming, today, tasks, reminders, goals];
List<Object?> get props => [
upcoming,
today,
tasks,
reminders,
goals,
weatherPlace,
];
}

View file

@ -5,6 +5,7 @@ import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.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';
/// The schema of a task's payload this app writes and reads. It stays 1
/// as fields are added (ADR 0033, D17, D18): an older version reads a task
@ -29,6 +30,7 @@ const _known = {
'reminders',
'reminderTimes',
'locations',
'weather',
'completedAt',
'completions',
'hideDoneSubTasks',
@ -67,7 +69,9 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
{'day': '${at.day}', 'time': at.time},
],
if (content.locations.isNotEmpty)
'locations': [for (final at in content.locations) _encodeLocation(at)],
'locations': [for (final at in content.locations) encodeLocation(at)],
if (content.weather case final weather? when !weather.isEmpty)
'weather': _encodeWeather(weather),
if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty)
@ -77,7 +81,9 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true,
});
Map<String, Object?> _encodeLocation(TaskLocation location) => {
/// A location as a payload keeps it (D32): the task's, or the settings'
/// place of the forecast (D40).
Map<String, Object?> encodeLocation(TaskLocation location) => {
'name': location.name,
'lat': location.lat,
'lon': location.lon,
@ -88,6 +94,14 @@ Map<String, Object?> _encodeLocation(TaskLocation location) => {
'trigger': location.trigger.name,
};
Map<String, Object?> _encodeWeather(WeatherNeeds needs) => {
if (needs.dry) 'dry': true,
if (needs.sunny) 'sunny': true,
if (needs.calm) 'calm': true,
'from': ?needs.from,
'to': ?needs.to,
};
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
'unit': repeat.unit.name,
if (repeat.every != 1) 'every': repeat.every,
@ -145,8 +159,13 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
],
locations: [
for (final at in json['locations'] as List<Object?>? ?? const [])
_location(at),
decodeLocation(at),
],
weather: switch (json['weather']) {
final Map<String, Object?> weather => _weather(weather),
null => null,
_ => throw const FormatException('weather'),
},
completions: [
for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(),
@ -171,9 +190,9 @@ ReminderTime _reminderTime(Object? json) {
throw const FormatException('reminderTimes');
}
/// A location as [_encodeLocation] wrote it (D32); throws for one that
/// A location as [encodeLocation] wrote it (D32); throws for one that
/// isn't, so that its task isn't read half.
TaskLocation _location(Object? json) {
TaskLocation decodeLocation(Object? json) {
if (json
case {
'name': final String name,
@ -201,6 +220,24 @@ TaskLocation _location(Object? json) {
throw const FormatException('locations');
}
/// The weather a task needs as [_encodeWeather] wrote it (D40); none where
/// it needs nothing. A temperature that isn't a number throws.
WeatherNeeds? _weather(Map<String, Object?> json) {
double? degrees(Object? value) => switch (value) {
final num celsius when celsius.isFinite => celsius.toDouble(),
null => null,
_ => throw const FormatException('weather'),
};
final needs = WeatherNeeds(
dry: json['dry'] == true,
sunny: json['sunny'] == true,
calm: json['calm'] == true,
from: degrees(json['from']),
to: degrees(json['to']),
);
return needs.isEmpty ? null : needs;
}
TaskDate _date(Map<String, Object?> json) {
final day = Day.tryParse(json['day']! as String);
final time = json['time'] as int?;

View file

@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/weather_needs.dart';
/// A day of the calendar, as a task's date and deadline name it (ADR 0033,
/// D6): the person's own, the same wherever they are.
@ -173,6 +174,9 @@ final class const TaskContent({
/// it on arriving or leaving where it says so.
final List<TaskLocation> locations = const [],
/// The weather it needs (D40), its forecast judged by; none: any.
final WeatherNeeds? weather,
/// When it was done; none: not yet.
final DateTime? completedAt,
@ -207,6 +211,7 @@ final class const TaskContent({
List<int>? reminders,
List<ReminderTime>? reminderTimes,
List<TaskLocation>? locations,
WeatherNeeds? Function()? weather,
DateTime? Function()? completedAt,
List<DateTime>? completions,
bool? hideDoneSubTasks,
@ -224,6 +229,7 @@ final class const TaskContent({
reminders: reminders ?? this.reminders,
reminderTimes: reminderTimes ?? this.reminderTimes,
locations: locations ?? this.locations,
weather: weather == null ? this.weather : weather(),
completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions,
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
@ -245,6 +251,7 @@ final class const TaskContent({
reminders,
reminderTimes,
locations,
weather,
completedAt,
completions,
hideDoneSubTasks,

View file

@ -22,15 +22,23 @@ typedef DateChoice = ({TaskDate? date});
/// A time or duration picked counts however the sheet is closed (the
/// developer's review, 4 Nov 2026: closed after a time, it had kept none):
/// on [current]'s day, or today where there was none, as Todoist's.
///
/// Where the task needs weather (ADR 0033, D40), [fitting] are the coming
/// days its forecast fits, offered under the choices; none fitting says so.
Future<DateChoice?> pickTaskDate(
BuildContext context, {
TaskDate? current,
List<Day>? fitting,
}) async {
final pending = ValueNotifier<DateChoice?>(null);
try {
final chosen = await showAdaptiveSheet<DateChoice>(
context,
builder: (context) => _DateSheet(current: current, pending: pending),
builder: (context) => _DateSheet(
current: current,
pending: pending,
fitting: fitting,
),
);
return chosen ?? pending.value;
} finally {
@ -38,6 +46,9 @@ Future<DateChoice?> pickTaskDate(
}
}
/// The key of the date picker's row of days the weather fits (D40).
const fittingDaysKey = Key('fittingDays');
/// Asks for a deadline, a day alone: none to clear it, null where the
/// person changed their mind.
Future<({Day? day})?> pickDeadline(BuildContext context, {Day? current}) async {
@ -54,6 +65,7 @@ Future<({Day? day})?> pickDeadline(BuildContext context, {Day? current}) async {
class const _DateSheet({
required final TaskDate? current,
required final ValueNotifier<DateChoice?> pending,
final List<Day>? fitting,
}) extends StatefulWidget {
@override
State<_DateSheet> createState() => _DateSheetState();
@ -125,6 +137,29 @@ class _DateSheetState() extends State<_DateSheet> {
if (weekend != today && weekend != today.plus(1))
option(LucideIcons.sofa, l10n.dateWeekend, weekend),
option(LucideIcons.calendarArrowUp, l10n.dateNextWeek, nextWeek),
if (widget.fitting case final fitting?) ...[
ListTile(
leading: const Icon(LucideIcons.cloudSun),
title: Text(l10n.weatherFittingDays),
subtitle: fitting.isEmpty ? Text(l10n.weatherNoFittingDay) : null,
),
if (fitting.isNotEmpty)
SingleChildScrollView(
key: fittingDaysKey,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(
spacing: 8,
children: [
for (final day in fitting)
ActionChip(
label: Text(dayText(context, day, today)),
onPressed: () => _choose(day, repeat: _repeat),
),
],
),
),
],
ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(l10n.datePick),

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/bloc/forecast_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -16,10 +17,11 @@ class const TaskRefresh({
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => AppRefreshIndicator(
// Their calendars fetched again too (D39).
// Their calendars fetched again too (D39), and the forecasts (D40).
onRefresh: () => Future.wait([
syncAndWait(context.read<SyncStatusBloc>()),
context.read<CalendarCubit>().refresh(),
context.read<ForecastCubit>().refresh(),
]),
child: fill ? PullRoom(child: child) : child,
);

View file

@ -32,6 +32,8 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.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/feature/weather/widget/weather_widgets.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -431,6 +433,51 @@ class _TaskSheetState() extends State<_TaskSheet> {
);
}
/// The weather the task needs (D40) and what its forecast says of it: a
/// mark and what it misses where it doesn't fit.
Widget _weather(BuildContext context, TaskContent content) {
final l10n = AppLocalizations.of(context);
final needs = content.weather;
final missed = weatherMisses(context, content);
final misfit = missed != null && missed.isNotEmpty;
final error = Theme.of(context).colorScheme.error;
return ListTile(
leading: Icon(
misfit ? LucideIcons.cloudAlert : LucideIcons.cloudSun,
color: misfit ? error : null,
),
title: Text(
needs == null ? l10n.weatherTitle : weatherNeedsText(context, needs),
),
subtitle: switch (missed) {
null => null,
final missed when missed.isEmpty => Text(l10n.weatherFits),
final missed => Text(
weatherMissesText(context, missed),
style: TextStyle(color: error),
),
},
trailing: needs == null
? null
: IconButton(
tooltip: l10n.clear,
icon: const Icon(LucideIcons.x),
onPressed: () => _change((c) => c.copyWith(weather: () => null)),
),
onTap: () => _pickWeather(context, needs),
);
}
/// Asks what weather the task needs, starting from [needs].
Future<void> _pickWeather(BuildContext context, WeatherNeeds? needs) async {
final picked = await pickWeatherNeeds(context, needs);
if (picked != null) {
await _change(
(c) => c.copyWith(weather: () => picked.isEmpty ? null : picked),
);
}
}
Future<void> _change(TaskContent Function(TaskContent content) change) async {
final content = _content;
if (content == null) {
@ -900,9 +947,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
// The days the weather fits, where it needs any (D40).
final forecast = forecastOfTask(
context,
content,
listen: false,
);
final needs = content.weather;
if (await pickTaskDate(
context,
current: content.date,
fitting: needs == null || forecast == null
? null
: fittingDays(needs, content.date, forecast, today),
)
case (:final date)) {
await _change((c) => c.copyWith(date: () => date));
}
},
@ -916,6 +975,8 @@ class _TaskSheetState() extends State<_TaskSheet> {
),
),
_Box(child: _where(context, content)),
// Once it needs weather (D40); until then a chip below.
if (content.weather != null) _Box(child: _weather(context, content)),
_Box(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
@ -974,6 +1035,13 @@ class _TaskSheetState() extends State<_TaskSheet> {
}
},
),
// The weather it needs (D40): a row of its own once set.
if (content.weather == null)
ActionChip(
avatar: const Icon(LucideIcons.cloudSun),
label: Text(l10n.weatherTitle),
onPressed: () => _pickWeather(context, null),
),
if (content.parentId == null)
ActionChip(
avatar: Icon(

View file

@ -18,6 +18,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.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_writes.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/widget/weather_widgets.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -56,6 +57,9 @@ class const TaskTile({
final ValueChanged<String>? onOpen,
super.key,
}) extends StatelessWidget {
/// The key of the mark of a task whose forecast doesn't fit (D40).
static const weatherMarkKey = Key('weatherMark');
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -114,6 +118,18 @@ class const TaskTile({
dateText(context, date, today),
dateColor(context, date.day, today, done: content.completed),
),
// Its forecast no longer fitting the weather it needs (D40).
if (weatherMisses(context, content) case final missed?
when missed.isNotEmpty)
Tooltip(
key: TaskTile.weatherMarkKey,
message: weatherMissesText(context, missed),
child: Icon(
LucideIcons.cloudAlert,
size: 14,
color: theme.colorScheme.error,
),
),
if (content.deadline case final deadline?)
meta(LucideIcons.target, dayText(context, deadline, today), muted),
if (locationsText(content.locations) case final place?)

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.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/weather/data/weather_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
/// The forecasts held (ADR 0033, D40): each place's hours, by its position
/// rounded ([roundedPlace]).
final class const ForecastState({
final Map<LatLon, List<ForecastHour>> forecasts = const {},
}) with Equatable {
/// The forecast for [place]; none where none is held.
List<ForecastHour>? at(TaskLocation place) =>
forecasts[roundedPlace(place.lat, place.lon)];
@override
List<Object?> get props => [forecasts];
}
/// The most places forecasts are asked for: a person's own place, else
/// those of their tasks tied to the weather.
const maxForecastPlaces = 10;
/// The places [state] needs forecasts for (D40): the one set in Settings,
/// else the first place of each open task with a date tied to the weather,
/// [maxForecastPlaces] at most.
Set<LatLon> forecastPlaces(TodoState state) {
if (state.settings.weatherPlace case final place?) {
return {roundedPlace(place.lat, place.lon)};
}
return {
for (final task in state.tasks)
if (task.content case final content
when !content.completed &&
content.date != null &&
!(content.weather?.isEmpty ?? true))
if (content.locations.firstOrNull case final place?)
roundedPlace(place.lat, place.lon),
}.take(maxForecastPlaces).toSet();
}
/// The place [content]'s forecast is for (D40): the person's own, else its
/// first place; none without either.
TaskLocation? forecastPlaceOf(TodoState state, TaskContent content) =>
state.settings.weatherPlace ?? content.locations.firstOrNull;
/// Follows the forecasts of the places [forecastPlaces] names (D40): asked
/// for as a place is followed, every hour while the app is open ([start])
/// and when a list is pulled down ([refresh]); held in memory alone.
class ForecastCubit(final WeatherGateway _weather)
extends Cubit<ForecastState> {
this : super(const ForecastState());
var _places = <LatLon>{};
Timer? _hourly;
/// Asks for every forecast again every hour, until closed.
void start() {
_hourly?.cancel();
_hourly = Timer.periodic(const Duration(hours: 1), (_) => refresh());
}
/// Follows [places]: a new one's forecast is asked for, one no longer
/// followed forgotten.
Future<void> follow(Set<LatLon> places) async {
final fresh = places.difference(_places);
_places = places;
if (state.forecasts.keys.any((place) => !places.contains(place))) {
_emit({
for (final MapEntry(:key, :value) in state.forecasts.entries)
if (places.contains(key)) key: value,
});
}
await Future.wait([for (final place in fresh) _fetch(place)]);
}
/// Asks for every forecast followed again.
Future<void> refresh() async {
await Future.wait([for (final place in _places) _fetch(place)]);
}
Future<void> _fetch(LatLon place) async {
final List<ForecastHour> hours;
try {
hours = await _weather.forecast(place);
} on WeatherUnavailable {
// The last forecast stays, where there was one.
return;
}
if (!isClosed && _places.contains(place)) {
_emit({...state.forecasts, place: hours});
}
}
void _emit(Map<LatLon, List<ForecastHour>> forecasts) {
if (!isClosed) {
emit(ForecastState(forecasts: forecasts));
}
}
@override
Future<void> close() {
_hourly?.cancel();
return super.close();
}
}

View file

@ -0,0 +1,67 @@
import 'dart:developer';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/forecast.dart';
/// No forecast came: the instance refused it (switched off, too often) or
/// couldn't be asked.
final class const WeatherUnavailable() implements Exception;
/// A place's forecast through the instance (ADR 0033, D40, as Train's ADR
/// 0036, D1), so that Open-Meteo never sees the person's address.
abstract interface class WeatherGateway() {
/// Whether the instance gives forecasts; false where it can't be asked.
Future<bool> offered();
/// The hours of the coming days at [place], which leaves the device
/// rounded to about a kilometre; [WeatherUnavailable] where none came.
Future<List<ForecastHour>> forecast(LatLon place);
}
/// [WeatherGateway] on Todo's server (`weather`).
class InstanceWeather(final protocol.Client _client) implements WeatherGateway {
@override
Future<bool> offered() async {
try {
return await _client.weather.offered();
} on Object {
return false;
}
}
@override
Future<List<ForecastHour>> forecast(LatLon place) async {
final (:lat, :lon) = roundedPlace(place.lat, place.lon);
try {
return [
for (final hour in await _client.weather.forecast(lat: lat, lon: lon))
ForecastHour(
time: hour.time.toUtc(),
temperature: hour.temperature,
precipitation: hour.precipitation,
precipitationChance: hour.precipitationChance,
code: hour.code,
wind: hour.wind,
gusts: hour.gusts,
),
];
} on protocol.WeatherRefused catch (refused) {
log('No forecast: ${refused.reason}', name: 'Weather');
throw const WeatherUnavailable();
} on Exception catch (error) {
log('No forecast: ${error.runtimeType}', name: 'Weather');
throw const WeatherUnavailable();
}
}
}
/// Gives no forecast, where none is given (tests that don't say).
class const NoWeather() implements WeatherGateway {
@override
Future<bool> offered() async => false;
@override
Future<List<ForecastHour>> forecast(LatLon place) async =>
throw const WeatherUnavailable();
}

View file

@ -0,0 +1,232 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/model/weather_needs.dart';
export 'weather_needs.dart';
/// An hour of a place's forecast (ADR 0033, D40), from the instance: the
/// hour starting at [time] (UTC), each figure null where it has none.
final class const ForecastHour({
required final DateTime time,
/// At two metres, in °C.
final double? temperature,
/// What is to fall over the hour, in mm.
final double? precipitation,
/// The chance that anything falls, in percent.
final int? precipitationChance,
/// The WMO weather code.
final int? code,
/// The wind at ten metres, in m/s, and its gusts.
final double? wind,
final double? gusts,
}) with Equatable {
@override
List<Object?> get props => [
time,
temperature,
precipitation,
precipitationChance,
code,
wind,
gusts,
];
}
/// What the sky does, from the WMO weather code, as Train's (ADR 0036).
enum Conditions() {
clear,
partlyCloudy,
cloudy,
fog,
drizzle,
rain,
freezingRain,
snow,
rainShowers,
snowShowers,
thunderstorm;
/// The conditions [code] names; null for one WMO doesn't define.
static Conditions? of(int code) => switch (code) {
0 => clear,
1 || 2 => partlyCloudy,
3 => cloudy,
45 || 48 => fog,
51 || 53 || 55 || 56 || 57 => drizzle,
61 || 63 || 65 => rain,
66 || 67 => freezingRain,
71 || 73 || 75 || 77 => snow,
80 || 81 || 82 => rainShowers,
85 || 86 => snowShowers,
95 || 96 || 99 => thunderstorm,
_ => null,
};
}
/// Dry: under this much an hour, in mm, and a chance of rain under
/// [dryChance] percent (D40, the agent's).
const dryUnder = 0.1;
const dryChance = 40;
/// Sunny: clear, mainly clear or partly cloudy (WMO codes up to this).
const sunnyCodes = 2;
/// Calm: wind under 20 km/h and gusts under 40 km/h, in m/s.
const calmWind = 20 / 3.6;
const calmGusts = 40 / 3.6;
/// The hours of a day a task without a time needs its weather over: 8:00
/// to 20:00.
const dayFrom = 8;
const dayUntil = 20;
/// A place's position rounded as it leaves the device, about a kilometre
/// (as Train's, ADR 0036, D2): forecasts are held by it.
LatLon roundedPlace(double lat, double lon) =>
(lat: (lat * 100).round() / 100, lon: (lon * 100).round() / 100);
/// When [date] needs its weather, on the device's clock: from its time to
/// its end, an hour at least; a day without a time from [dayFrom] to
/// [dayUntil].
(DateTime, DateTime) weatherSpan(TaskDate date) {
final midnight = date.day.toDate();
if (date.time case final time?) {
final from = midnight.add(Duration(minutes: time));
final length = date.duration ?? Duration.zero;
return (
from,
from.add(
length < const Duration(hours: 1) ? const Duration(hours: 1) : length,
),
);
}
return (
midnight.add(const Duration(hours: dayFrom)),
midnight.add(const Duration(hours: dayUntil)),
);
}
/// The hours of [forecast] that [from] to [until] runs into; none where
/// the forecast doesn't reach over all of it.
List<ForecastHour>? hoursOver(
List<ForecastHour> forecast,
DateTime from,
DateTime until,
) {
if (forecast.isEmpty ||
forecast.first.time.isAfter(from) ||
forecast.last.time.add(const Duration(hours: 1)).isBefore(until)) {
return null;
}
return [
for (final hour in forecast)
if (hour.time.isBefore(until) &&
hour.time.add(const Duration(hours: 1)).isAfter(from))
hour,
];
}
/// What of [needs] [hours] don't give; a figure the forecast lacks counts
/// as given.
Set<WeatherNeed> missing(WeatherNeeds needs, List<ForecastHour> hours) {
bool any(bool Function(ForecastHour hour) misses) => hours.any(misses);
return {
if (needs.dry &&
any(
(hour) =>
(hour.precipitation ?? 0) >= dryUnder ||
(hour.precipitationChance ?? 0) >= dryChance,
))
WeatherNeed.dry,
if (needs.sunny && any((hour) => (hour.code ?? 0) > sunnyCodes))
WeatherNeed.sunny,
if ((needs.from, needs.to) case (final from, final to)
when (from != null || to != null) &&
any(
(hour) => switch (hour.temperature) {
final t? =>
(from != null && t < from) || (to != null && t > to),
null => false,
},
))
WeatherNeed.temperature,
if (needs.calm &&
any(
(hour) =>
(hour.wind ?? 0) >= calmWind || (hour.gusts ?? 0) >= calmGusts,
))
WeatherNeed.calm,
};
}
/// What [needs] miss on [date] by [forecast]; none where the forecast
/// doesn't reach that far, or the task needs nothing.
Set<WeatherNeed>? missingOn(
WeatherNeeds needs,
TaskDate date,
List<ForecastHour> forecast,
) {
if (needs.isEmpty) {
return null;
}
final (from, until) = weatherSpan(date);
return switch (hoursOver(forecast, from, until)) {
final hours? => missing(needs, hours),
null => null,
};
}
/// A day's weather as its heading shows it: the conditions of its worst
/// hour by day ([dayFrom] to [dayUntil]), and its lowest and highest
/// temperature.
typedef DayWeather = ({Conditions? conditions, double? low, double? high});
/// [day]'s weather by [forecast]; none where the forecast doesn't hold all
/// of it.
DayWeather? weatherOn(Day day, List<ForecastHour> forecast) {
final midnight = day.toDate();
final hours = hoursOver(forecast, midnight, day.plus(1).toDate());
if (hours == null || hours.isEmpty) {
return null;
}
final daytime = hoursOver(
hours,
midnight.add(const Duration(hours: dayFrom)),
midnight.add(const Duration(hours: dayUntil)),
);
final codes = [for (final hour in daytime ?? hours) ?hour.code];
final temperatures = [for (final hour in hours) ?hour.temperature];
return (
conditions: codes.isEmpty ? null : Conditions.of(codes.reduce(math.max)),
low: temperatures.isEmpty ? null : temperatures.reduce(math.min),
high: temperatures.isEmpty ? null : temperatures.reduce(math.max),
);
}
/// The days from [today] on whose forecast gives all of [needs] (D40), as
/// the date picker marks them: each judged at [date]'s time and for its
/// duration, or by day where it has none; the days the forecast doesn't
/// reach left out.
List<Day> fittingDays(
WeatherNeeds needs,
TaskDate? date,
List<ForecastHour> forecast,
Day today,
) => [
for (var i = 0; i <= 16; i++)
if (missingOn(
needs,
TaskDate(today.plus(i), time: date?.time, duration: date?.duration),
forecast,
)
case final missed? when missed.isEmpty)
today.plus(i),
];

View file

@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
/// One thing a task can need of the weather (D40).
enum WeatherNeed() {
dry,
sunny,
temperature,
calm,
}
/// What a task needs of the weather (D40): [dry], [sunny], [calm], and its
/// hours' temperatures [from] and [to] (°C, either open).
final class const WeatherNeeds({
final bool dry = false,
final bool sunny = false,
final bool calm = false,
final double? from,
final double? to,
}) with Equatable {
/// *Not freezing*: from this on.
static const notFreezing = 1.0;
/// *Warm*: from this on.
static const warm = 18.0;
/// Whether it needs nothing.
bool get isEmpty => !dry && !sunny && !calm && from == null && to == null;
/// What it needs, in [WeatherNeed]'s order.
Set<WeatherNeed> get needs => {
if (dry) WeatherNeed.dry,
if (sunny) WeatherNeed.sunny,
if (from != null || to != null) WeatherNeed.temperature,
if (calm) WeatherNeed.calm,
};
WeatherNeeds copyWith({
bool? dry,
bool? sunny,
bool? calm,
double? Function()? from,
double? Function()? to,
}) => WeatherNeeds(
dry: dry ?? this.dry,
sunny: sunny ?? this.sunny,
calm: calm ?? this.calm,
from: from == null ? this.from : from(),
to: to == null ? this.to : to(),
);
@override
List<Object?> get props => [dry, sunny, calm, from, to];
}

Some files were not shown because too many files have changed in this diff Show more