feat(todo): the weather: a forecast on days, tasks tied to it, the days that fit (ADR 0033, D40)
A task can need weather: dry, sunny, calm and a temperature range (not freezing, warm, or from and to as typed), set from Weather in its details (a chip beside Priority, then a row of its own once it needs any) and kept in its sealed payload (`weather`, schema stays 1). Where its day's forecast misses any of it, its row carries a mark and its details say what is missing; nothing moves and its reminder stays. Its date picker lists the coming days the forecast fits, at its time or from 8:00 to 20:00 without one. The forecast is for the place set in Settings' new Weather card, kept in the sealed settings; else, for a task with a place, its first place. The device's location is never read for it. `ForecastCubit` asks the instance for each place (`InstanceWeather`, rounded to about a kilometre) as it comes, as the app opens, hourly and on a pull, and holds the answers in memory alone. With a place in Settings, each day's heading in Today and Upcoming shows its sky and temperatures, the 3-day grid its icon. Settings says where the instance gives no forecasts, and credits Open-Meteo as its licence asks. Whats-New: A task can need dry, sunny, calm or mild weather: it is marked when the forecast doesn't fit, and its date picker shows the days that do; days show the forecast for the place set in Settings. Whats-New-De: Eine Aufgabe kann trockenes, sonniges, windstilles oder mildes Wetter brauchen: Sie wird markiert, wenn die Vorhersage nicht passt, und ihre Datumsauswahl zeigt die passenden Tage; die Tage zeigen die Vorhersage für den Ort aus den Einstellungen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a91aa98817
commit
8502fb38f0
32 changed files with 2315 additions and 22 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
|
|
@ -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
Loading…
Reference in a new issue