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)", "projectCalendarAddress": "Kalenderadresse (iCal)",
"projectCalendarHelp": "Seine Termine stehen neben den Aufgaben dieses Projekts, nur zum Lesen. Jedes deiner Geräte ruft ihn selbst ab.", "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", "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" "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: /// In en, this message translates to:
/// **'From {project}\'s calendar'** /// **'From {project}\'s calendar'**
String eventFromCalendar(String project); 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 class _AppLocalizationsDelegate

View file

@ -1608,4 +1608,128 @@ class AppLocalizationsDe extends AppLocalizations {
String eventFromCalendar(String project) { String eventFromCalendar(String project) {
return 'Aus dem Kalender von $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) { String eventFromCalendar(String project) {
return 'From $project\'s calendar'; 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`). /// 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/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/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.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:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -134,19 +135,41 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
)..start(); )..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. // Where sync stands: Settings' card and the dot on the cog.
BlocProvider( BlocProvider(
create: (context) => create: (context) =>
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync), SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
), ),
], ],
child: BlocListener<TodoCubit, TodoState>( child: MultiBlocListener(
// A project linked to a calendar, or unlinked, on any device. listeners: [
listenWhen: (before, now) => BlocListener<TodoCubit, TodoState>(
!listEquals(before.projects, now.projects), // A project linked to a calendar, or unlinked, on any
listener: (context, state) => unawaited( // device.
context.read<CalendarCubit>().follow(state.projects), 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( child: TaskRevealScope(
notifier: _reveal, notifier: _reveal,
child: ScreenColor( 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/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/sync_task_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.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:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -221,6 +222,10 @@ class TodoDependencies({
/// placed by hand. /// placed by hand.
final PlaceSearchGateway? places, 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, /// The tile layer of the location picker's map; none in widget tests,
/// which reach no network. /// which reach no network.
final MapTiles? mapTiles, final MapTiles? mapTiles,
@ -324,6 +329,7 @@ class TodoDependencies({
places: _publicPhoton places: _publicPhoton
? PublicPhotonGateway() ? PublicPhotonGateway()
: InstancePlaceSearch.of(client), : InstancePlaceSearch.of(client),
weather: InstanceWeather(client),
mapTiles: vectorTiles, mapTiles: vectorTiles,
sourceCodeUrl: () async => sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl, (await client.modules.base.instance.basics()).sourceCodeUrl,

View file

@ -1,8 +1,10 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart'; 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/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.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'; import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// The schema of the settings' payload this app writes and reads. /// The schema of the settings' payload this app writes and reads.
@ -42,6 +44,8 @@ ByteData encodeSettings(TodoSettings settings) =>
], ],
'celebrated': ?settings.goals.celebrated?.toString(), 'celebrated': ?settings.goals.celebrated?.toString(),
}, },
if (settings.weatherPlace case final place?)
'weather': {'place': encodeLocation(place)},
}); });
Map<String, Object?> _encodeUpcoming(UpcomingView view) => { Map<String, Object?> _encodeUpcoming(UpcomingView view) => {
@ -62,9 +66,23 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
tasks: _decodeTasks(json['tasks']), tasks: _decodeTasks(json['tasks']),
reminders: _decodeReminders(json['reminders']), reminders: _decodeReminders(json['reminders']),
goals: _decodeGoals(json['goals']), 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) { GoalSettings _decodeGoals(Object? json) {
if (json is! Map<String, Object?>) { if (json is! Map<String, Object?>) {
return const GoalSettings(); return const GoalSettings();

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; 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/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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, /// 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 /// 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 class const TodoSettings({
final UpcomingView upcoming = const UpcomingView(), final UpcomingView upcoming = const UpcomingView(),
final TodayView today = const TodayView(), final TodayView today = const TodayView(),
final TaskSettings tasks = const TaskSettings(), final TaskSettings tasks = const TaskSettings(),
final ReminderSettings reminders = const ReminderSettings(), final ReminderSettings reminders = const ReminderSettings(),
final GoalSettings goals = const GoalSettings(), final GoalSettings goals = const GoalSettings(),
final TaskLocation? weatherPlace,
}) with Equatable { }) with Equatable {
TodoSettings copyWith({ TodoSettings copyWith({
UpcomingView? upcoming, UpcomingView? upcoming,
@ -191,14 +195,23 @@ final class const TodoSettings({
TaskSettings? tasks, TaskSettings? tasks,
ReminderSettings? reminders, ReminderSettings? reminders,
GoalSettings? goals, GoalSettings? goals,
TaskLocation? Function()? weatherPlace,
}) => TodoSettings( }) => TodoSettings(
upcoming: upcoming ?? this.upcoming, upcoming: upcoming ?? this.upcoming,
today: today ?? this.today, today: today ?? this.today,
tasks: tasks ?? this.tasks, tasks: tasks ?? this.tasks,
reminders: reminders ?? this.reminders, reminders: reminders ?? this.reminders,
goals: goals ?? this.goals, goals: goals ?? this.goals,
weatherPlace: weatherPlace == null ? this.weatherPlace : weatherPlace(),
); );
@override @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/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/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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 /// 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 /// as fields are added (ADR 0033, D17, D18): an older version reads a task
@ -29,6 +30,7 @@ const _known = {
'reminders', 'reminders',
'reminderTimes', 'reminderTimes',
'locations', 'locations',
'weather',
'completedAt', 'completedAt',
'completions', 'completions',
'hideDoneSubTasks', 'hideDoneSubTasks',
@ -67,7 +69,9 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
{'day': '${at.day}', 'time': at.time}, {'day': '${at.day}', 'time': at.time},
], ],
if (content.locations.isNotEmpty) 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?) if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(), 'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty) if (content.completions.isNotEmpty)
@ -77,7 +81,9 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true, 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, 'name': location.name,
'lat': location.lat, 'lat': location.lat,
'lon': location.lon, 'lon': location.lon,
@ -88,6 +94,14 @@ Map<String, Object?> _encodeLocation(TaskLocation location) => {
'trigger': location.trigger.name, '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) => { Map<String, Object?> _encodeRepeat(Repeat repeat) => {
'unit': repeat.unit.name, 'unit': repeat.unit.name,
if (repeat.every != 1) 'every': repeat.every, if (repeat.every != 1) 'every': repeat.every,
@ -145,8 +159,13 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
], ],
locations: [ locations: [
for (final at in json['locations'] as List<Object?>? ?? const []) 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: [ completions: [
for (final at in json['completions'] as List<Object?>? ?? const []) for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(), DateTime.parse(at! as String).toLocal(),
@ -171,9 +190,9 @@ ReminderTime _reminderTime(Object? json) {
throw const FormatException('reminderTimes'); 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. /// isn't, so that its task isn't read half.
TaskLocation _location(Object? json) { TaskLocation decodeLocation(Object? json) {
if (json if (json
case { case {
'name': final String name, 'name': final String name,
@ -201,6 +220,24 @@ TaskLocation _location(Object? json) {
throw const FormatException('locations'); 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) { TaskDate _date(Map<String, Object?> json) {
final day = Day.tryParse(json['day']! as String); final day = Day.tryParse(json['day']! as String);
final time = json['time'] as int?; 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_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.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/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, /// 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. /// 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. /// it on arriving or leaving where it says so.
final List<TaskLocation> locations = const [], 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. /// When it was done; none: not yet.
final DateTime? completedAt, final DateTime? completedAt,
@ -207,6 +211,7 @@ final class const TaskContent({
List<int>? reminders, List<int>? reminders,
List<ReminderTime>? reminderTimes, List<ReminderTime>? reminderTimes,
List<TaskLocation>? locations, List<TaskLocation>? locations,
WeatherNeeds? Function()? weather,
DateTime? Function()? completedAt, DateTime? Function()? completedAt,
List<DateTime>? completions, List<DateTime>? completions,
bool? hideDoneSubTasks, bool? hideDoneSubTasks,
@ -224,6 +229,7 @@ final class const TaskContent({
reminders: reminders ?? this.reminders, reminders: reminders ?? this.reminders,
reminderTimes: reminderTimes ?? this.reminderTimes, reminderTimes: reminderTimes ?? this.reminderTimes,
locations: locations ?? this.locations, locations: locations ?? this.locations,
weather: weather == null ? this.weather : weather(),
completedAt: completedAt == null ? this.completedAt : completedAt(), completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions, completions: completions ?? this.completions,
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks, hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
@ -245,6 +251,7 @@ final class const TaskContent({
reminders, reminders,
reminderTimes, reminderTimes,
locations, locations,
weather,
completedAt, completedAt,
completions, completions,
hideDoneSubTasks, hideDoneSubTasks,

View file

@ -22,15 +22,23 @@ typedef DateChoice = ({TaskDate? date});
/// A time or duration picked counts however the sheet is closed (the /// 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): /// 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. /// 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( Future<DateChoice?> pickTaskDate(
BuildContext context, { BuildContext context, {
TaskDate? current, TaskDate? current,
List<Day>? fitting,
}) async { }) async {
final pending = ValueNotifier<DateChoice?>(null); final pending = ValueNotifier<DateChoice?>(null);
try { try {
final chosen = await showAdaptiveSheet<DateChoice>( final chosen = await showAdaptiveSheet<DateChoice>(
context, context,
builder: (context) => _DateSheet(current: current, pending: pending), builder: (context) => _DateSheet(
current: current,
pending: pending,
fitting: fitting,
),
); );
return chosen ?? pending.value; return chosen ?? pending.value;
} finally { } 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 /// Asks for a deadline, a day alone: none to clear it, null where the
/// person changed their mind. /// person changed their mind.
Future<({Day? day})?> pickDeadline(BuildContext context, {Day? current}) async { 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({ class const _DateSheet({
required final TaskDate? current, required final TaskDate? current,
required final ValueNotifier<DateChoice?> pending, required final ValueNotifier<DateChoice?> pending,
final List<Day>? fitting,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
State<_DateSheet> createState() => _DateSheetState(); State<_DateSheet> createState() => _DateSheetState();
@ -125,6 +137,29 @@ class _DateSheetState() extends State<_DateSheet> {
if (weekend != today && weekend != today.plus(1)) if (weekend != today && weekend != today.plus(1))
option(LucideIcons.sofa, l10n.dateWeekend, weekend), option(LucideIcons.sofa, l10n.dateWeekend, weekend),
option(LucideIcons.calendarArrowUp, l10n.dateNextWeek, nextWeek), 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( ListTile(
leading: const Icon(LucideIcons.calendarDays), leading: const Icon(LucideIcons.calendarDays),
title: Text(l10n.datePick), title: Text(l10n.datePick),

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.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/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:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -16,10 +17,11 @@ class const TaskRefresh({
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => AppRefreshIndicator( Widget build(BuildContext context) => AppRefreshIndicator(
// Their calendars fetched again too (D39). // Their calendars fetched again too (D39), and the forecasts (D40).
onRefresh: () => Future.wait([ onRefresh: () => Future.wait([
syncAndWait(context.read<SyncStatusBloc>()), syncAndWait(context.read<SyncStatusBloc>()),
context.read<CalendarCubit>().refresh(), context.read<CalendarCubit>().refresh(),
context.read<ForecastCubit>().refresh(),
]), ]),
child: fill ? PullRoom(child: child) : child, 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_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.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/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/pane_widths.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart'; import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
import 'package:halkyon_ui/halkyon_ui.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 { Future<void> _change(TaskContent Function(TaskContent content) change) async {
final content = _content; final content = _content;
if (content == null) { if (content == null) {
@ -900,9 +947,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
? null ? null
: clear(() => _change((c) => c.copyWith(date: () => null))), : clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async { onTap: () async {
if (await pickTaskDate(context, current: content.date) case ( // The days the weather fits, where it needs any (D40).
:final date, 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)); await _change((c) => c.copyWith(date: () => date));
} }
}, },
@ -916,6 +975,8 @@ class _TaskSheetState() extends State<_TaskSheet> {
), ),
), ),
_Box(child: _where(context, content)), _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( _Box(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, 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) if (content.parentId == null)
ActionChip( ActionChip(
avatar: Icon( 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_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.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/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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -56,6 +57,9 @@ class const TaskTile({
final ValueChanged<String>? onOpen, final ValueChanged<String>? onOpen,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
/// The key of the mark of a task whose forecast doesn't fit (D40).
static const weatherMarkKey = Key('weatherMark');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -114,6 +118,18 @@ class const TaskTile({
dateText(context, date, today), dateText(context, date, today),
dateColor(context, date.day, today, done: content.completed), 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?) if (content.deadline case final deadline?)
meta(LucideIcons.target, dayText(context, deadline, today), muted), meta(LucideIcons.target, dayText(context, deadline, today), muted),
if (locationsText(content.locations) case final place?) 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