diff --git a/android/app/src/main/kotlin/com/marotidev/overmorrow/configurators/CurrentWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/com/marotidev/overmorrow/configurators/CurrentWidgetConfigurationActivity.kt index c6fa242d..30534494 100644 --- a/android/app/src/main/kotlin/com/marotidev/overmorrow/configurators/CurrentWidgetConfigurationActivity.kt +++ b/android/app/src/main/kotlin/com/marotidev/overmorrow/configurators/CurrentWidgetConfigurationActivity.kt @@ -306,7 +306,7 @@ class CurrentWidgetConfigurationActivity : ComponentActivity() { Log.i("LastKnownLocation", lastKnownLocation) Log.i("selectedBackground", selectedBackgroundOnStartup) - val providers : List = listOf("open-meteo", "weatherapi", "met-norway") + val providers : List = listOf("open-meteo", "weatherapi", "met-norway", "meteo-france") val backColors : List = listOf("secondary container", "primary container", "tertiary container", "surface", "transparent") val frontColors : List = listOf("primary", "secondary", "tertiary", "transparent") diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 89176ef4..366700f9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -15,6 +15,14 @@ subprojects { subprojects { project.evaluationDependsOn(":app") } +subprojects { + configurations.all { + resolutionStrategy { + force("androidx.glance:glance-appwidget:1.1.1") + force("androidx.glance:glance-material3:1.1.1") + } + } +} tasks.register("clean") { delete(rootProject.layout.buildDirectory) diff --git a/android/gradle.properties b/android/gradle.properties index 1a6f0195..08caa83b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,3 +3,7 @@ android.useAndroidX=true android.enableJetifier=true #I added this to fix a lint crash, can be removed afterwards android.experimental.lint.version=8.13.2 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/api_key_example.dart b/lib/api_key_example.dart index 49b94f1a..02d4a744 100644 --- a/lib/api_key_example.dart +++ b/lib/api_key_example.dart @@ -20,15 +20,11 @@ along with this program. If not, see . //To test this project with your own api key, first rename this file to api_key.dart. //Then add your own api keys below: -//IMPORTANT: Overmorrow has 3 weather providers (open-meteo and weatherapi and met-norway) -//but only weatherapi requires an api key. You don't need an api key for open-meteo or met-norway. +//IMPORTANT: Overmorrow has 4 weather providers (open-meteo, weatherapi, met-norway and meteo-france) +//but only weatherapi requires an api key. You don't need an api key for open-meteo, met-norway or meteo-france. -const String wapi_key = "YourWeatherApiKey"; //your api key from weatherapi.com -//the app works without this if you only use the open-meteo or met-norway providers +const String wapi_Key = "YourWeatherApiKey"; //your api key from weatherapi.com +//the app works without this if you only use the open-meteo, met-norway or meteo-france providers const String access_key = "YourUnsplashApiKey"; //your api key from unsplash.com //the app works without this if you set the image source to asset - -const String timezonedbKey = "YourTimezonedbKey"; //your api key from timezonedb.com -//the app works without this if you use open-meteo as weather provider -//both the others don't return local times so they need this instead diff --git a/lib/daily.dart b/lib/daily.dart index 4322b5e8..595c53cd 100644 --- a/lib/daily.dart +++ b/lib/daily.dart @@ -29,33 +29,46 @@ import 'package:overmorrow/weather_refact.dart'; import 'package:provider/provider.dart'; import 'l10n/app_localizations.dart'; - -Widget dayStat(IconData icon, num? number, addon, context, {int? windDir, iconSize = 16.0}) { +Widget dayStat(IconData icon, num? number, addon, context, + {int? windDir, iconSize = 16.0}) { return Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: Theme.of(context).colorScheme.primary, size: iconSize), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4), - child: Text(number != null ? number.toString() : "--", - style: TextStyle( - color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 17),), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + number != null ? number.toString() : "--", + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondaryContainer, + fontSize: 17), + ), + ), + if (number != null) + Text( + addon, + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondaryContainer, + fontSize: 15), + ), + ], ), - if (number != null) Text(addon, style: TextStyle( - color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 15),), - ], - ), - if (windDir != null) Padding( - padding: const EdgeInsets.only(left: 5, right: 3), - child: RotationTransition( - turns: AlwaysStoppedAnimation(windDir / 360), - child: Icon(Icons.arrow_circle_right_outlined, - color: Theme.of(context).colorScheme.primary, size: 18) - ) + ), ), + if (windDir != null) + Padding( + padding: const EdgeInsets.only(left: 2), + child: RotationTransition( + turns: AlwaysStoppedAnimation(windDir / 360), + child: Icon(Icons.arrow_circle_right_outlined, + color: Theme.of(context).colorScheme.primary, size: 16))), ], ); } @@ -69,8 +82,8 @@ class BuildDays extends StatefulWidget { _BuildDaysState createState() => _BuildDaysState(); } -class _BuildDaysState extends State with AutomaticKeepAliveClientMixin { - +class _BuildDaysState extends State + with AutomaticKeepAliveClientMixin { static const int maxToShow = 7; bool isExpanded = false; @@ -81,7 +94,8 @@ class _BuildDaysState extends State with AutomaticKeepAliveClientMixi @override void initState() { super.initState(); - for (int i = 0; i < 20; i++) { //there will never be more days than 20 + for (int i = 0; i < 20; i++) { + //there will never be more days than 20 expand.add(false); } } @@ -109,83 +123,100 @@ class _BuildDaysState extends State with AutomaticKeepAliveClientMixi crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(left: 1, bottom: 14), - child: Text(AppLocalizations.of(context)!.dailyLowercase, style: const TextStyle(fontSize: 17),) - ), - + padding: const EdgeInsets.only(left: 1, bottom: 14), + child: Text( + AppLocalizations.of(context)!.dailyLowercase, + style: const TextStyle(fontSize: 17), + )), AnimatedSize( duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, alignment: Alignment.topCenter, child: ListView.builder( - key: ValueKey(daysToShow), - shrinkWrap: true, - padding: const EdgeInsets.only(top: 0, bottom: 0), - physics: const NeverScrollableScrollPhysics(), - itemCount: daysToShow, - itemBuilder: (context, index) { - final day = widget.data.days[index]; - return Padding( - padding: const EdgeInsets.only(top: 2, bottom: 2), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.vertical( - top: index == 0 ? const Radius.circular(33) : const Radius.circular(6), - bottom: index == daysToShow - 1 && !showButton - ? const Radius.circular(33) : const Radius.circular(6), - ), - color: Theme.of(context).colorScheme.surfaceContainer - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - child: expand[index] ? DailyExpanded(day: day, onExpandTapped: _onExpandTapped, index: index) - : DailyCollapsed(data: widget.data, day: day, index: index, onExpandTapped: _onExpandTapped) - ) - ), - ); - } - ), + key: ValueKey(daysToShow), + shrinkWrap: true, + padding: const EdgeInsets.only(top: 0, bottom: 0), + physics: const NeverScrollableScrollPhysics(), + itemCount: daysToShow, + itemBuilder: (context, index) { + final day = widget.data.days[index]; + return Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical( + top: index == 0 + ? const Radius.circular(33) + : const Radius.circular(6), + bottom: index == daysToShow - 1 && !showButton + ? const Radius.circular(33) + : const Radius.circular(6), + ), + color: + Theme.of(context).colorScheme.surfaceContainer), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: expand[index] + ? DailyExpanded( + day: day, + onExpandTapped: _onExpandTapped, + index: index) + : DailyCollapsed( + data: widget.data, + day: day, + index: index, + onExpandTapped: _onExpandTapped))), + ); + }), ), - if (showButton) GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - HapticFeedback.mediumImpact(); - if (isExpanded) { - setState(() { - dayCap = 7; - isExpanded = false; - }); - } - else { - setState(() { - dayCap = 20; - isExpanded = true; - }); - } - }, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiaryContainer, - borderRadius: const BorderRadius.vertical(top: Radius.circular(6), bottom: Radius.circular(33)) - ), - padding: const EdgeInsets.only(left: 22, right: 22, top: 11, bottom: 11), - margin: const EdgeInsets.only(top: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + if (showButton) + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + HapticFeedback.mediumImpact(); + if (isExpanded) { + setState(() { + dayCap = 7; + isExpanded = false; + }); + } else { + setState(() { + dayCap = 20; + isExpanded = true; + }); + } + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiaryContainer, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(6), bottom: Radius.circular(33))), + padding: const EdgeInsets.only( + left: 22, right: 22, top: 11, bottom: 11), + margin: const EdgeInsets.only(top: 2), + child: + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - isExpanded ? AppLocalizations.of(context)!.showLess : AppLocalizations.of(context)!.showMore, - style: TextStyle(color: Theme.of(context).colorScheme.onTertiaryContainer, fontSize: 16), + isExpanded + ? AppLocalizations.of(context)!.showLess + : AppLocalizations.of(context)!.showMore, + style: TextStyle( + color: + Theme.of(context).colorScheme.onTertiaryContainer, + fontSize: 16), + ), + const SizedBox( + width: 4, ), - const SizedBox(width: 4,), Icon( isExpanded ? Icons.arrow_upward : Icons.arrow_downward, - color: Theme.of(context).colorScheme.onTertiaryContainer, size: 16,) - ] + color: Theme.of(context).colorScheme.onTertiaryContainer, + size: 16, + ) + ]), ), - ), - ) + ) ], ), ); @@ -198,20 +229,25 @@ class DailyCollapsed extends StatelessWidget { final WeatherDay day; final WeatherData data; - const DailyCollapsed({super.key, required this.onExpandTapped, - required this.index, required this.day, required this.data}); - + const DailyCollapsed( + {super.key, + required this.onExpandTapped, + required this.index, + required this.day, + required this.data}); @override Widget build(BuildContext context) { - String dayName = getDayName(day.date, context, context.select((SettingsProvider p) => p.getDateFormat)); + String dayName = getDayName(day.date, context, + context.select((SettingsProvider p) => p.getDateFormat)); return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { onExpandTapped(index); }, child: Padding( - padding: const EdgeInsets.only(left: 23, right: 23, top: 15, bottom: 15), + padding: + const EdgeInsets.only(left: 23, right: 23, top: 15, bottom: 15), child: Row( children: [ SizedBox( @@ -220,80 +256,102 @@ class DailyCollapsed extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(dayName.split(', ')[0], style: const TextStyle(fontSize: 18, height: 1.15),), - Text(dayName.split(', ')[1], style: TextStyle(fontSize: 12, - color: Theme.of(context).colorScheme.outline, height: 1.15, fontWeight: FontWeight.w600),), + Text( + dayName.split(', ')[0], + style: const TextStyle(fontSize: 18, height: 1.15), + ), + Text( + dayName.split(', ')[1], + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + height: 1.15, + fontWeight: FontWeight.w600), + ), ], ), ), - Stack( alignment: Alignment.center, children: [ SvgPicture.asset( "assets/m3shapes/4_sided_cookie.svg", - colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.secondaryContainer, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondaryContainer, + BlendMode.srcIn), width: 54, height: 54, ), SvgPicture.asset( - weatherIconPathMap[day.condition] ?? "assets/weather_icons/clear_sky.svg", - colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), + weatherIconPathMap[day.condition] ?? + "assets/weather_icons/clear_sky.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn), width: 35, height: 35, ) ], ), - SizedBox( width: 40, child: Align( - alignment: Alignment.centerRight, - child: Text( - "${unitConversion(day.minTempC, context.select((SettingsProvider p) => p.getTempUnit), decimals: 0)}°", - style: TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 18, fontWeight: FontWeight.w600), - ), + alignment: Alignment.centerRight, + child: Text( + "${unitConversion(day.minTempC, context.select((SettingsProvider p) => p.getTempUnit), decimals: 0)}°", + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 18, + fontWeight: FontWeight.w600), + ), ), ), Expanded( child: Container( - margin: const EdgeInsets.only(left: 14, right: 14), - height: 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.surfaceContainerHighest - ), - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final double width = constraints.maxWidth; - - final lowest = data.dailyMinMaxTemp[0]; - final highest = data.dailyMinMaxTemp[1]; - const double smallest = 18; - final double minPercent = min(max((day.minTempC - lowest) / (highest - lowest), 0), 1); - final double maxPercent = min(max((day.maxTempC - lowest) / (highest - lowest), 0), 1); - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: EdgeInsets.only(left: min(width * minPercent, width - smallest)), - width: max(smallest, (maxPercent - minPercent) * width), - height: 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.primaryFixedDim - ), - ), - ); - } + margin: const EdgeInsets.only(left: 14, right: 14), + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surfaceContainerHighest), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double width = constraints.maxWidth; + + final lowest = data.dailyMinMaxTemp[0]; + final highest = data.dailyMinMaxTemp[1]; + const double smallest = 18; + final double minPercent = min( + max((day.minTempC - lowest) / (highest - lowest), 0), 1); + final double maxPercent = min( + max((day.maxTempC - lowest) / (highest - lowest), 0), 1); + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: EdgeInsets.only( + left: min(width * minPercent, width - smallest)), + width: max(smallest, (maxPercent - minPercent) * width), + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.primaryFixedDim), ), - ) - ), + ); + }), + )), Text( "${unitConversion(day.maxTempC, context.select((SettingsProvider p) => p.getTempUnit), decimals: 0)}°", - style: TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 18, fontWeight: FontWeight.w600), + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 18, + fontWeight: FontWeight.w600), + ), + const SizedBox( + width: 12, ), - const SizedBox(width: 12,), - Icon(Icons.expand_more, size: 23, color: Theme.of(context).colorScheme.onSurface,) + Icon( + Icons.expand_more, + size: 23, + color: Theme.of(context).colorScheme.onSurface, + ) ], ), ), @@ -301,18 +359,21 @@ class DailyCollapsed extends StatelessWidget { } } - class DailyExpanded extends StatelessWidget { final Function onExpandTapped; final int index; final WeatherDay day; - const DailyExpanded({super.key, required this.onExpandTapped, - required this.index, required this.day}); + const DailyExpanded( + {super.key, + required this.onExpandTapped, + required this.index, + required this.day}); @override Widget build(BuildContext context) { - String dayName = getDayName(day.date, context, context.select((SettingsProvider p) => p.getDateFormat)); + String dayName = getDayName(day.date, context, + context.select((SettingsProvider p) => p.getDateFormat)); return Padding( padding: const EdgeInsets.only(left: 14, right: 14, top: 0, bottom: 16), child: Column( @@ -328,11 +389,22 @@ class DailyExpanded extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: 8,), - Text(dayName, style: const TextStyle(fontSize: 17),), + const SizedBox( + width: 8, + ), + Text( + dayName, + style: const TextStyle(fontSize: 17), + ), const Spacer(), - Icon(Icons.expand_less, size: 23, color: Theme.of(context).colorScheme.onSurface,), - const SizedBox(width: 9,), + Icon( + Icons.expand_less, + size: 23, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox( + width: 9, + ), ], ), ), @@ -346,29 +418,35 @@ class DailyExpanded extends StatelessWidget { children: [ SvgPicture.asset( "assets/m3shapes/4_sided_cookie.svg", - colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.secondaryContainer, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondaryContainer, + BlendMode.srcIn), width: 60, height: 60, ), SvgPicture.asset( - weatherIconPathMap[day.condition] ?? "assets/weather_icons/clear_sky.svg", - colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), + weatherIconPathMap[day.condition] ?? + "assets/weather_icons/clear_sky.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn), width: 38, height: 38, ) ], ), - Expanded( child: Padding( - padding: const EdgeInsets.only(left: 15), - child: Text( - conditionTranslation(day.condition, AppLocalizations.of(context)!) ?? "Translation Err", - style: TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 22), - ) - ), + padding: const EdgeInsets.only(left: 15), + child: Text( + conditionTranslation( + day.condition, AppLocalizations.of(context)!) ?? + "Translation Err", + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 22), + )), ), - Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.tertiaryContainer, @@ -377,16 +455,36 @@ class DailyExpanded extends StatelessWidget { padding: const EdgeInsets.all(9), child: Row( children: [ - Icon(Icons.keyboard_double_arrow_down, size: 16, color: Theme.of(context).colorScheme.onTertiaryContainer,), + Icon( + Icons.keyboard_double_arrow_down, + size: 16, + color: + Theme.of(context).colorScheme.onTertiaryContainer, + ), Text( "${unitConversion(day.minTempC, context.select((SettingsProvider p) => p.getTempUnit), decimals: 0)}°", - style: TextStyle(color: Theme.of(context).colorScheme.onTertiaryContainer, fontSize: 17), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onTertiaryContainer, + fontSize: 17), + ), + const SizedBox( + width: 6, + ), + Icon( + Icons.keyboard_double_arrow_up, + size: 16, + color: + Theme.of(context).colorScheme.onTertiaryContainer, ), - const SizedBox(width: 6,), - Icon(Icons.keyboard_double_arrow_up, size: 16, color: Theme.of(context).colorScheme.onTertiaryContainer,), Text( "${unitConversion(day.maxTempC, context.select((SettingsProvider p) => p.getTempUnit), decimals: 0)}°", - style: TextStyle(color: Theme.of(context).colorScheme.onTertiaryContainer, fontSize: 17), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onTertiaryContainer, + fontSize: 17), ), ], ), @@ -397,50 +495,59 @@ class DailyExpanded extends StatelessWidget { Container( decoration: BoxDecoration( //color: Theme.of(context).colorScheme.tertiaryContainer, - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant, width: 2), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 2), borderRadius: BorderRadius.circular(18), ), - padding: const EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 20), + padding: + const EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 20), margin: const EdgeInsets.all(2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - dayStat( - Icons.umbrella_rounded, - day.precipProb, "%", - context + Expanded( + child: dayStat( + Icons.umbrella_rounded, day.precipProb, "%", context), ), - dayStat( - Icons.water_drop_outlined, - unitConversion(day.totalPrecipMm, context.select((SettingsProvider p) => p.getPrecipUnit), decimals: 1), - context.select((SettingsProvider p) => p.getPrecipUnit), - context, - iconSize: 16.5 + Expanded( + child: dayStat( + Icons.water_drop_outlined, + unitConversion( + day.totalPrecipMm, + context + .select((SettingsProvider p) => p.getPrecipUnit), + decimals: 1), + context.select((SettingsProvider p) => p.getPrecipUnit), + context, + iconSize: 16.5), ), - dayStat( - Icons.air, - unitConversion(day.windKmh, context.select((SettingsProvider p) => p.getWindUnit), decimals: 1), - context.select((SettingsProvider p) => p.getWindUnit), - context, - windDir: day.windDirA + Expanded( + child: dayStat( + Icons.air, + unitConversion(day.windKmh, + context.select((SettingsProvider p) => p.getWindUnit), + decimals: 1), + context.select((SettingsProvider p) => p.getWindUnit), + context, + windDir: day.windDirA), ), - dayStat( - Icons.wb_sunny_outlined, - day.uv, - "uv", - context + Expanded( + child: + dayStat(Icons.wb_sunny_outlined, day.uv, "uv", context), ), ], ), ), - Padding( padding: const EdgeInsets.only(top: 20), - child: NewHourly(hours: day.hourly, elevated: true,), + child: NewHourly( + hours: day.hourly, + elevated: true, + ), ) - ], ), ); } -} \ No newline at end of file +} diff --git a/lib/decoders/decode_mf.dart b/lib/decoders/decode_mf.dart new file mode 100644 index 00000000..49536382 --- /dev/null +++ b/lib/decoders/decode_mf.dart @@ -0,0 +1,995 @@ +/* +Copyright (C) <2026> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../services/caching_service.dart'; +import '../services/weather_service.dart'; +import 'decode_OM.dart'; +import 'decode_RV.dart'; +import 'weather_data.dart'; + +const String _mfApiHost = 'webservice.meteofrance.com'; +const String _mfApiToken = '__Wj7dVSTjV9YGu1guveLyDq0g7S7TfTjaHBTPTpO0kj8__'; + +const Map _mfWarningPhenomenons = { + '1': 'Wind', + '2': 'Rain-Flood', + '3': 'Thunderstorms', + '4': 'Flood', + '5': 'Snow/Ice', + '6': 'Heat wave', + '7': 'Extreme cold', + '8': 'Avalanches', + '9': 'Coastal event', +}; + +const Map _mfWarningColors = { + 1: 'green', + 2: 'yellow', + 3: 'orange', + 4: 'red', +}; + +const List _mfProbabilityWindows = ['1h', '3h', '6h', '12h', '24h']; + +double _mfDouble(dynamic value, [double fallback = 0]) { + if (value is num) { + return value.toDouble(); + } + if (value is String) { + return double.tryParse(value) ?? fallback; + } + return fallback; +} + +int? _mfInt(dynamic value) { + if (value is num) { + return value.round(); + } + if (value is String) { + return double.tryParse(value)?.round(); + } + return null; +} + +DateTime _mfDateFromTimestamp(dynamic timestamp) { + return DateTime.fromMillisecondsSinceEpoch( + (_mfDouble(timestamp) * 1000).round(), + isUtc: true, + ).toLocal(); +} + +Duration _mfAbsoluteDifference(DateTime a, DateTime b) { + return Duration(microseconds: a.difference(b).inMicroseconds.abs()); +} + +double _mfNestedDouble(dynamic item, String section, String key, + [double fallback = 0]) { + if (item is Map && item[section] is Map) { + return _mfDouble(item[section][key], fallback); + } + return fallback; +} + +int? _mfNestedInt(dynamic item, String section, String key) { + if (item is Map && item[section] is Map) { + return _mfInt(item[section][key]); + } + return null; +} + +String _mfNormalizeText(dynamic value) { + return (value ?? '') + .toString() + .toLowerCase() + .replaceAll('é', 'e') + .replaceAll('è', 'e') + .replaceAll('ê', 'e') + .replaceAll('ë', 'e') + .replaceAll('à', 'a') + .replaceAll('â', 'a') + .replaceAll('î', 'i') + .replaceAll('ï', 'i') + .replaceAll('ô', 'o') + .replaceAll('ù', 'u') + .replaceAll('û', 'u') + .replaceAll('ç', 'c'); +} + +bool _mfIsNight(DateTime time, WeatherSunStatus? sunStatus, String icon) { + final normalizedIcon = icon.toLowerCase(); + if (normalizedIcon.endsWith('n') || normalizedIcon.contains('nuit')) { + return true; + } + if (sunStatus == null) { + return false; + } + + final sameDayTime = sunStatus.sunrise.copyWith( + hour: time.hour, + minute: time.minute, + ); + return sameDayTime.difference(sunStatus.sunrise).isNegative || + sunStatus.sunset.difference(sameDayTime).isNegative; +} + +String mfTextCorrection(dynamic weather, DateTime time, + {WeatherSunStatus? sunStatus}) { + final String icon = weather is Map ? (weather['icon'] ?? '').toString() : ''; + final String desc = + _mfNormalizeText(weather is Map ? weather['desc'] : weather); + final bool isNight = _mfIsNight(time, sunStatus, icon); + + if (desc.contains('orage')) { + return 'Thunderstorm'; + } + if (desc.contains('neige')) { + if (desc.contains('fort') || desc.contains('abond')) { + return 'Heavy Snow'; + } + return 'Snow'; + } + if (desc.contains('gresil') || + desc.contains('verglas') || + desc.contains('verglac')) { + return 'Sleet'; + } + if (desc.contains('pluie') || + desc.contains('averse') || + desc.contains('precipitation')) { + if (desc.contains('fort') || + desc.contains('intense') || + desc.contains('tres')) { + return 'Heavy Rain'; + } + if (desc.contains('faible') || desc.contains('bruine')) { + return 'Drizzle'; + } + return 'Rain'; + } + if (desc.contains('bruine')) { + return 'Drizzle'; + } + if (desc.contains('brouillard') || desc.contains('brume')) { + return 'Fog'; + } + if (desc.contains('couvert') || desc.contains('tres nuageux')) { + return 'Overcast'; + } + if (desc.contains('eclaircie') || + desc.contains('peu nuageux') || + desc.contains('variable') || + desc.contains('nuage')) { + return isNight ? 'Cloudy Night' : 'Partly Cloudy'; + } + if (desc.contains('soleil') || + desc.contains('clair') || + desc.contains('ensoleille') || + desc.contains('beau temps')) { + return isNight ? 'Clear Night' : 'Clear Sky'; + } + return isNight ? 'Clear Night' : 'Clear Sky'; +} + +Future> mfMakeForecastRequest( + double lat, double lon, String place) async { + final params = { + 'lat': lat.toString(), + 'lon': lon.toString(), + 'lang': 'fr', + 'token': _mfApiToken, + }; + final url = Uri.https(_mfApiHost, 'forecast', params); + + final file = await XCustomCacheManager.fetchData( + url.toString(), + '$place, meteo-france forecast', + ); + + final response = await file[0].readAsString(); + return [jsonDecode(response), await file[0].lastModified(), file[1]]; +} + +Future mfMakeObservationRequest( + double lat, double lon, String place) async { + final params = { + 'lat': lat.toString(), + 'lon': lon.toString(), + 'lang': 'fr', + 'token': _mfApiToken, + }; + final url = Uri.https(_mfApiHost, 'v2/observation', params); + + try { + final file = await XCustomCacheManager.fetchData( + url.toString(), + '$place, meteo-france observation', + ); + return jsonDecode(await file[0].readAsString()); + } catch (_) { + return null; + } +} + +Future mfMakeRainRequest(double lat, double lon, String place) async { + final params = { + 'lat': lat.toString(), + 'lon': lon.toString(), + 'lang': 'fr', + 'token': _mfApiToken, + }; + final url = Uri.https(_mfApiHost, 'rain', params); + + try { + final file = await XCustomCacheManager.fetchData( + url.toString(), + '$place, meteo-france rain', + ); + return jsonDecode(await file[0].readAsString()); + } catch (_) { + return null; + } +} + +Future mfGetLightForecastResponse(double lat, double lon) async { + final params = { + 'lat': lat.toString(), + 'lon': lon.toString(), + 'lang': 'fr', + 'token': _mfApiToken, + }; + final url = Uri.https(_mfApiHost, 'forecast', params); + final response = await http.get(url); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException('Meteo France forecast failed: ${response.statusCode}'); + } + return jsonDecode(response.body); +} + +dynamic _mfFirstOrEmpty(List values) { + return values.isEmpty ? {} : values.first; +} + +WeatherSunStatus mfWeatherSunStatusFromDaily( + dynamic daily, DateTime localTime) { + final sun = daily is Map && daily['sun'] is Map ? daily['sun'] : null; + final sunrise = sun == null + ? DateTime(localTime.year, localTime.month, localTime.day, 6) + : _mfDateFromTimestamp(sun['rise']); + final sunset = sun == null + ? DateTime(localTime.year, localTime.month, localTime.day, 18) + : _mfDateFromTimestamp(sun['set']); + final daylightMinutes = max(sunset.difference(sunrise).inMinutes, 1); + + return WeatherSunStatus( + sunrise: sunrise, + sunset: sunset, + sunstatus: min( + max(localTime.difference(sunrise).inMinutes / daylightMinutes, 0), + 1, + ), + ); +} + +dynamic _mfNearestForecast(List forecast, DateTime localTime) { + if (forecast.isEmpty) { + return {}; + } + return forecast.reduce((a, b) { + final aDiff = + _mfAbsoluteDifference(_mfDateFromTimestamp(a['dt']), localTime); + final bDiff = + _mfAbsoluteDifference(_mfDateFromTimestamp(b['dt']), localTime); + return aDiff.compareTo(bDiff) <= 0 ? a : b; + }); +} + +Map _mfProbabilityByTimestamp(List probabilities) { + return { + for (final item in probabilities) + if (item is Map && item['dt'] != null) _mfInt(item['dt']) ?? 0: item + }; +} + +int? _mfPrecipProbabilityForWindow(dynamic item, String window) { + if (item is! Map) { + return null; + } + + final values = []; + for (final section in ['rain', 'snow']) { + if (item[section] is Map) { + final value = _mfInt(item[section][window]); + if (value != null) { + values.add(value); + } + } + } + + if (values.isEmpty) { + return null; + } + return values.reduce(max); +} + +int? _mfPrecipProbabilityForTimestamp( + Map probabilityByDt, + int timestamp, +) { + int? bestValue; + int? bestWindowSeconds; + int? bestDistanceSeconds; + + for (final entry in probabilityByDt.entries) { + final probabilityTimestamp = entry.key; + + for (final window in _mfProbabilityWindows) { + final windowHours = int.tryParse(window.replaceAll('h', '')); + final value = _mfPrecipProbabilityForWindow(entry.value, window); + + if (windowHours == null || value == null) { + continue; + } + + final windowSeconds = windowHours * 60 * 60; + if (timestamp < probabilityTimestamp - windowSeconds || + timestamp > probabilityTimestamp) { + continue; + } + + final distanceSeconds = (probabilityTimestamp - timestamp).abs(); + final isBetter = bestValue == null || + windowSeconds < bestWindowSeconds! || + (windowSeconds == bestWindowSeconds && + distanceSeconds < bestDistanceSeconds!); + + if (isBetter) { + bestValue = value; + bestWindowSeconds = windowSeconds; + bestDistanceSeconds = distanceSeconds; + } + } + } + + return bestValue; +} + +double _mfPrecipMm(dynamic item) { + return _mfNestedDouble(item, 'rain', '1h') + + _mfNestedDouble(item, 'snow', '1h'); +} + +WeatherHour mfWeatherHourFromJson( + dynamic item, + Map probabilityByDt, + int? uv, + WeatherSunStatus sunStatus, +) { + final timestamp = _mfInt(item['dt']) ?? 0; + final time = _mfDateFromTimestamp(timestamp); + + return WeatherHour( + tempC: _mfNestedDouble(item, 'T', 'value'), + time: time, + condition: mfTextCorrection(item['weather'], time, sunStatus: sunStatus), + precipMm: _mfPrecipMm(item), + precipProb: _mfPrecipProbabilityForTimestamp(probabilityByDt, timestamp), + windKmh: _mfNestedDouble(item, 'wind', 'speed'), + windDirA: _mfNestedInt(item, 'wind', 'direction'), + windGustKmh: _mfNestedDouble(item, 'wind', 'gust'), + uv: uv, + ); +} + +List mfBuildWeatherHourList( + List forecast, + Map probabilityByDt, + DateTime dayDate, + DateTime localTime, + int? uv, + WeatherSunStatus sunStatus, +) { + final threshold = + DateTime(localTime.year, localTime.month, localTime.day, localTime.hour); + final hours = []; + + for (final item in forecast) { + final time = _mfDateFromTimestamp(item['dt']); + final isSameDay = time.year == dayDate.year && + time.month == dayDate.month && + time.day == dayDate.day; + + if (isSameDay && !time.isBefore(threshold)) { + hours.add(mfWeatherHourFromJson(item, probabilityByDt, uv, sunStatus)); + } + } + + return hours; +} + +WeatherDay mfWeatherDayFromJson( + dynamic item, + List forecast, + Map probabilityByDt, + DateTime localTime, +) { + final date = _mfDateFromTimestamp(item['dt']); + final uv = _mfInt(item['uv']); + final sunStatus = mfWeatherSunStatusFromDaily(item, localTime); + final hours = mfBuildWeatherHourList( + forecast, + probabilityByDt, + date, + localTime, + uv, + sunStatus, + ); + + final windSpeeds = hours.map((hour) => hour.windKmh).toList(); + final windDirections = + hours.map((hour) => hour.windDirA).whereType().toList(); + final precipProbabilities = + hours.map((hour) => hour.precipProb).whereType().toList(); + + return WeatherDay( + condition: mfTextCorrection(item['weather12H'], date), + date: DateTime(date.year, date.month, date.day), + minTempC: _mfNestedDouble(item, 'T', 'min'), + maxTempC: _mfNestedDouble(item, 'T', 'max'), + hourly: hours, + precipProb: + precipProbabilities.isEmpty ? null : precipProbabilities.reduce(max), + totalPrecipMm: _mfNestedDouble(item, 'precipitation', '24h'), + windKmh: windSpeeds.isEmpty ? 0 : windSpeeds.reduce(max), + windDirA: windDirections.isEmpty + ? null + : (windDirections.reduce((a, b) => a + b) / windDirections.length) + .round(), + uv: uv, + ); +} + +WeatherCurrent mfWeatherCurrentFromJson( + dynamic forecast, + dynamic observation, + dynamic today, + WeatherSunStatus sunStatus, + DateTime localTime, +) { + final gridded = observation is Map && + observation['properties'] is Map && + observation['properties']['gridded'] is Map + ? observation['properties']['gridded'] + : null; + + final observedTemp = + gridded == null ? null : _mfDouble(gridded['T'], double.nan); + final observedWind = + gridded == null ? null : _mfDouble(gridded['wind_speed'], double.nan); + final observedWindDir = + gridded == null ? null : _mfInt(gridded['wind_direction']); + final observedWeather = gridded == null + ? null + : { + 'icon': gridded['weather_icon'], + 'desc': gridded['weather_description'], + }; + + final temp = observedTemp == null || observedTemp.isNaN + ? _mfNestedDouble(forecast, 'T', 'value') + : observedTemp; + + return WeatherCurrent( + condition: mfTextCorrection( + observedWeather ?? forecast['weather'], localTime, + sunStatus: sunStatus), + tempC: temp, + humidity: _mfInt(forecast['humidity']) ?? 0, + feelsLikeC: _mfNestedDouble(forecast, 'T', 'windchill', temp), + uv: _mfInt(today['uv']) ?? 0, + precipMm: _mfNestedDouble(today, 'precipitation', '24h'), + windKmh: observedWind == null || observedWind.isNaN + ? _mfNestedDouble(forecast, 'wind', 'speed') + : observedWind, + windDirA: + observedWindDir ?? _mfNestedInt(forecast, 'wind', 'direction') ?? 0, + ); +} + +WeatherRain15Minutes mfWeatherRain15MinutesFromRain(dynamic item) { + final rawForecast = item is Map && item['forecast'] is List + ? List.from(item['forecast']) + : []; + final chunks = List.filled(4, 0); + + if (rawForecast.isNotEmpty) { + final firstTime = _mfDateFromTimestamp(rawForecast.first['dt']); + + for (final item in rawForecast) { + final minutes = max( + 0, + _mfDateFromTimestamp(item['dt']).difference(firstTime).inMinutes, + ); + final index = min(3, minutes ~/ 15); + final rainCode = _mfInt(item['rain']) ?? 1; + final value = switch (rainCode) { + 2 => 0.2, + 3 => 0.8, + 4 => 1.6, + _ => 0.0, + }; + + chunks[index] = max(chunks[index], value); + } + } + + final sum = chunks.reduce((a, b) => a + b); + + int closest = 100; + int end = -1; + for (int i = 0; i < chunks.length; i++) { + if (chunks[i] > 0) { + closest = min(closest, i); + end = max(end, i); + } + } + + String text = ''; + int time = 0; + if (closest != 100) { + if (closest <= 1) { + if (end == 1) { + text = 'rainInHalfHour'; + } else if (end <= 2) { + time = [15, 30, 45][end]; + text = 'rainInMinutes'; + } else if (end ~/ 4 == 1) { + text = 'rainInOneHour'; + } else { + time = (end + 2) ~/ 4; + text = 'rainInHours'; + } + } else if (closest < 4) { + time = [15, 30, 45][closest - 1]; + text = 'rainExpectedInMinutes'; + } else if ((closest + 2) ~/ 4 == 1) { + text = 'rainExpectedInOneHour'; + } else { + time = (closest + 2) ~/ 4; + text = 'rainExpectedInHours'; + } + } + + return WeatherRain15Minutes( + text: text, + timeTo: time, + precipSumMm: sum > 0 ? max(sum, 0.1) : 0, + precipListMm: chunks, + ); +} + +WeatherRain15Minutes mfWeatherRain15MinutesFromHours(List hourly) { + int closest = 100; + int end = -1; + double sum = 0; + + final precips = []; + final source = hourly + .take(6) + .map((hour) => double.parse(hour.precipMm.toStringAsFixed(1))) + .toList(); + + for (int i = 0; i < source.length; i++) { + if (source[i] > 0) { + closest = min(closest, i + 1); + end = max(end, i + 1); + } + } + + for (int i = 0; i < source.length - 1; i++) { + final now = source[i]; + final next = source[i + 1]; + final dif = next - now; + + for (double x = 0; x <= 1; x += 0.25) { + final value = (now + dif * x) / 4; + sum += value; + precips.add(value); + } + } + + int time = 0; + String text = ''; + if (closest != 100) { + if (closest <= 2) { + if (end <= 1) { + text = 'rainInOneHour'; + } else { + text = 'rainInHours'; + time = end; + } + } else { + text = 'rainExpectedInHours'; + time = closest; + } + } + + return WeatherRain15Minutes( + text: text, + timeTo: time, + precipSumMm: sum > 0 ? max(sum, 0.1) : 0, + precipListMm: precips, + ); +} + +String _mfWarningDescription(dynamic full, String phenomenonId) { + final parts = []; + + if (full is Map && + full['comments'] is Map && + full['comments']['text'] is List) { + parts.addAll((full['comments']['text'] as List).whereType()); + } + + final blocs = full is Map && full['text'] is Map + ? full['text']['text_bloc_item'] + : null; + if (blocs is List) { + for (final bloc in blocs) { + final textItems = bloc is Map ? bloc['text_items'] : null; + if (textItems is! List) { + continue; + } + for (final textItem in textItems) { + if (textItem is! Map || + textItem['hazard_code']?.toString() != phenomenonId) { + continue; + } + final termItems = textItem['term_items']; + if (termItems is! List) { + continue; + } + for (final term in termItems) { + final subdivisions = term is Map ? term['subdivision_text'] : null; + if (subdivisions is! List) { + continue; + } + for (final subdivision in subdivisions) { + if (subdivision is! Map) { + continue; + } + final title = (subdivision['bold_text'] ?? '').toString().trim(); + final texts = subdivision['text']; + if (title.isNotEmpty) { + parts.add(title); + } + if (texts is List) { + parts.addAll(texts + .whereType() + .where((text) => text.trim().isNotEmpty)); + } + } + } + } + } + } + + return parts.isEmpty ? 'Meteo France vigilance bulletin.' : parts.join('\n'); +} + +Future> mfGetWeatherAlerts(dynamic position) async { + if (position is! Map || position['dept'] == null) { + return []; + } + + final dept = position['dept'].toString(); + final params = { + 'domain': dept, + 'token': _mfApiToken, + }; + + try { + final fullUrl = Uri.https(_mfApiHost, 'v3/warning/full', params); + final fullFile = await XCustomCacheManager.fetchData( + fullUrl.toString(), + '$dept, meteo-france warnings', + ); + final full = jsonDecode(await fullFile[0].readAsString()); + + final dictionaryUrl = Uri.https(_mfApiHost, 'v3/warning/dictionary', { + 'lang': 'fr', + 'token': _mfApiToken, + }); + final dictionaryFile = await XCustomCacheManager.fetchData( + dictionaryUrl.toString(), + 'meteo-france warning dictionary', + ); + final dictionary = jsonDecode(await dictionaryFile[0].readAsString()); + + final phenomenonNames = Map.from(_mfWarningPhenomenons); + final colorNames = Map.from(_mfWarningColors); + + if (dictionary is Map && dictionary['phenomenons'] is List) { + for (final phenomenon in dictionary['phenomenons']) { + if (phenomenon is Map) { + phenomenonNames[phenomenon['id'].toString()] = + phenomenon['name'].toString(); + } + } + } + if (dictionary is Map && dictionary['colors'] is List) { + for (final color in dictionary['colors']) { + if (color is Map) { + final id = _mfInt(color['id']); + if (id != null) { + colorNames[id] = color['name'].toString(); + } + } + } + } + + final alerts = []; + final phenomenons = full is Map && full['phenomenons_items'] is List + ? full['phenomenons_items'] as List + : []; + + for (final item in phenomenons) { + if (item is! Map) { + continue; + } + final colorId = _mfInt(item['phenomenon_max_color_id']) ?? 1; + if (colorId <= 1) { + continue; + } + + final phenomenonId = item['phenomenon_id'].toString(); + final phenomenonName = phenomenonNames[phenomenonId] ?? 'Weather alert'; + final colorName = colorNames[colorId] ?? 'unknown'; + DateTime? start; + DateTime? end; + + if (full['timelaps'] is List) { + for (final timelaps in full['timelaps']) { + if (timelaps is! Map || + timelaps['phenomenon_id'].toString() != phenomenonId) { + continue; + } + final timelapsItems = timelaps['timelaps_items']; + if (timelapsItems is! List || timelapsItems.isEmpty) { + continue; + } + final first = timelapsItems.first; + final last = timelapsItems.last; + if (first is Map) { + start = _mfDateFromTimestamp(first['begin_time']); + } + if (last is Map) { + end = _mfDateFromTimestamp(last['end_time']); + } + } + } + + alerts.add( + WeatherAlert( + headline: 'Vigilance $colorName: $phenomenonName', + start: start, + end: end, + desc: _mfWarningDescription(full, phenomenonId), + event: phenomenonName, + urgency: colorName, + severity: colorName, + certainty: '--', + areas: '${position['name'] ?? 'Meteo France'} ($dept)', + ), + ); + } + + return alerts; + } catch (_) { + return []; + } +} + +Future MfGetWeatherData(lat, lng, placeName) async { + final mf = await mfMakeForecastRequest(lat, lng, placeName); + final body = mf[0]; + final fetchDatetime = mf[1]; + final isOnline = mf[2]; + + final forecast = List.from(body['forecast'] ?? []); + final dailyForecast = List.from(body['daily_forecast'] ?? []); + + if (forecast.isEmpty || dailyForecast.isEmpty) { + throw const SocketException('Meteo France forecast unavailable'); + } + + final localTime = DateTime.now(); + final today = dailyForecast.first; + final sunStatus = mfWeatherSunStatusFromDaily(today, localTime); + final probabilityByDt = _mfProbabilityByTimestamp( + List.from(body['probability_forecast'] ?? []), + ); + + final days = []; + final hourly72 = []; + + for (final item in dailyForecast) { + final day = + mfWeatherDayFromJson(item, forecast, probabilityByDt, localTime); + days.add(day); + + for (final hour in day.hourly) { + if (hourly72.length < 72) { + hourly72.add(hour); + } + } + } + + if (hourly72.isEmpty) { + throw const SocketException('Meteo France cached data expired'); + } + + final currentForecast = _mfNearestForecast(forecast, localTime); + final observation = await mfMakeObservationRequest(lat, lng, placeName); + final rain = + body['position'] is Map && body['position']['rain_product_available'] == 1 + ? await mfMakeRainRequest(lat, lng, placeName) + : null; + + return WeatherData( + place: placeName, + lat: lat, + lng: lng, + provider: 'meteo-france', + updatedTime: DateTime.now(), + fetchDatetime: fetchDatetime, + localTime: localTime, + isOnline: isOnline, + days: days, + hourly72: hourly72, + current: mfWeatherCurrentFromJson( + currentForecast, observation, today, sunStatus, localTime), + aqi: await oMGetWeatherAqi(lat, lng), + sunStatus: sunStatus, + minutely15Precip: rain == null + ? mfWeatherRain15MinutesFromHours(hourly72) + : mfWeatherRain15MinutesFromRain(rain), + alerts: await mfGetWeatherAlerts(body['position']), + radar: await RainviewerRadar.getData(), + dailyMinMaxTemp: weatherGetMaxMinTempForDaily(days), + ); +} + +Future mfGetLightCurrentData( + placeName, + lat, + lon, + SharedPreferences prefs, +) async { + final item = await mfGetLightForecastResponse(lat, lon); + final forecast = List.from(item['forecast'] ?? []); + final daily = List.from(item['daily_forecast'] ?? []); + final now = DateTime.now(); + final current = _mfNearestForecast(forecast, now); + final sunStatus = mfWeatherSunStatusFromDaily(_mfFirstOrEmpty(daily), now); + + return LightCurrentWeatherData( + place: placeName, + temp: unitConversion( + _mfNestedDouble(current, 'T', 'value'), + prefs.getString('Temperature') ?? 'ËšC', + ).round(), + condition: mfTextCorrection(current['weather'], now, sunStatus: sunStatus), + updatedTime: '${now.hour}:${now.minute.toString().padLeft(2, '0')}', + dateString: getDateStringFromLocalTime(now), + ); +} + +Future mfGetLightWindData( + lat, lon, SharedPreferences prefs) async { + final item = await mfGetLightForecastResponse(lat, lon); + final forecast = List.from(item['forecast'] ?? []); + final current = _mfNearestForecast(forecast, DateTime.now()); + + return LightWindData( + windSpeed: unitConversion( + _mfNestedDouble(current, 'wind', 'speed'), + prefs.getString('Wind') ?? 'm/s', + ).round(), + windDirAngle: _mfNestedInt(current, 'wind', 'direction') ?? 0, + windUnit: prefs.getString('Wind') ?? 'm/s', + ); +} + +Future mfGetLightUvData(lat, lon, SharedPreferences prefs) async { + final item = await mfGetLightForecastResponse(lat, lon); + final daily = List.from(item['daily_forecast'] ?? []); + + return LightUvData( + uv: _mfInt(_mfFirstOrEmpty(daily)['uv']) ?? 0, + ); +} + +Future mfGetLightHourlyData( + placeName, + lat, + lon, + SharedPreferences prefs, +) async { + final item = await mfGetLightForecastResponse(lat, lon); + final forecast = List.from(item['forecast'] ?? []); + final daily = List.from(item['daily_forecast'] ?? []); + final now = DateTime.now(); + final current = _mfNearestForecast(forecast, now); + final sunStatus = mfWeatherSunStatusFromDaily(_mfFirstOrEmpty(daily), now); + + final hourly6Conditions = []; + final hourly6Temps = []; + final hourly6Names = []; + + final hourly1Conditions = []; + final hourly1Temps = []; + final hourly1Names = []; + + final tempUnit = prefs.getString('Temperature') ?? 'ËšC'; + final timeMode = prefs.getString('Time mode') ?? '12 hour'; + + for (final hour in forecast) { + final time = _mfDateFromTimestamp(hour['dt']); + + if (time.hour % 6 == 0 && hourly6Conditions.length < 4) { + hourly6Conditions + .add(mfTextCorrection(hour['weather'], time, sunStatus: sunStatus)); + hourly6Temps.add( + unitConversion(_mfNestedDouble(hour, 'T', 'value'), tempUnit) + .round()); + hourly6Names.add(formatHourByTimeMode(time, timeMode)); + } + + if (!time.isBefore(now) && hourly1Conditions.length < 4) { + hourly1Conditions + .add(mfTextCorrection(hour['weather'], time, sunStatus: sunStatus)); + hourly1Temps.add( + unitConversion(_mfNestedDouble(hour, 'T', 'value'), tempUnit) + .round()); + hourly1Names.add(formatHourByTimeMode(time, timeMode)); + } + } + + return LightHourlyForecastData( + currentTemp: + unitConversion(_mfNestedDouble(current, 'T', 'value'), tempUnit) + .round(), + currentCondition: + mfTextCorrection(current['weather'], now, sunStatus: sunStatus), + place: placeName, + updatedTime: '${now.hour}:${now.minute.toString().padLeft(2, '0')}', + hourly6Conditions: jsonEncode(hourly6Conditions), + hourly6Temps: jsonEncode(hourly6Temps), + hourly6Names: jsonEncode(hourly6Names), + hourly1Conditions: jsonEncode(hourly1Conditions), + hourly1Temps: jsonEncode(hourly1Temps), + hourly1Names: jsonEncode(hourly1Names), + ); +} diff --git a/lib/decoders/decode_mn.dart b/lib/decoders/decode_mn.dart index 2f2f2fa8..2d374364 100644 --- a/lib/decoders/decode_mn.dart +++ b/lib/decoders/decode_mn.dart @@ -20,35 +20,22 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:http/http.dart' as http; -import 'package:intl/intl.dart'; import 'package:overmorrow/decoders/decode_OM.dart'; import 'package:overmorrow/services/weather_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../api_key.dart'; import '../services/caching_service.dart'; +import '../services/timezone_service.dart'; import '../weather_refact.dart'; import 'decode_RV.dart'; import 'weather_data.dart'; - String metNTextCorrection(String text) { String p = metNWeatherToText[text] ?? 'Clear Sky'; return p; } -int metNCalculateHourDif(DateTime timeThere) { - DateTime now = DateTime.now().toUtc(); - - return now.hour - timeThere.hour; -} - -Duration metNCalculateTimeOffset(DateTime timeThere) { - DateTime now = DateTime.now().toUtc(); - return now.difference(timeThere); -} - double metNcalculateFeelsLike(double t, double r, double v) { //unfortunately met norway has no feels like temperatures, so i have to calculate it myself based on: //temperature, relative humidity, and wind speed @@ -57,64 +44,106 @@ double metNcalculateFeelsLike(double t, double r, double v) { if (t >= 24) { t = (t * 1.8) + 32; - double heat_index = -42.379 + (2.04901523 * t) + (10.14333127 * r) - - (0.22475541 * t * r) - (0.00683783 * t * t) - - (0.05481717 * r * r) + (0.00122874 * t * t * r) - + (0.00085282 * t * r * r) - (0.00000199 * t * t * r * r); + double heat_index = -42.379 + + (2.04901523 * t) + + (10.14333127 * r) - + (0.22475541 * t * r) - + (0.00683783 * t * t) - + (0.05481717 * r * r) + + (0.00122874 * t * t * r) + + (0.00085282 * t * r * r) - + (0.00000199 * t * t * r * r); return ((heat_index - 32) / 1.8); - } - - else if (t <= 13) { + } else if (t <= 13) { t = (t * 1.8) + 32; - double wind_chill = 35.74 + (0.6215 * t) - (35.75 * pow(v, 0.16)) + (0.4275 * t * pow(v, 0.16)); + double wind_chill = 35.74 + + (0.6215 * t) - + (35.75 * pow(v, 0.16)) + + (0.4275 * t * pow(v, 0.16)); return ((wind_chill - 32) / 1.8); + } else { + return t; } +} - else { - return t; +DateTime metNLocalTimeFromJson(item, double lat, double lng) { + return TimezoneService.localDateTimeFromUtc( + lat, lng, DateTime.parse(item["time"])); +} + +bool metNIsSameLocalDate(DateTime first, DateTime second) { + return first.year == second.year && + first.month == second.month && + first.day == second.day; +} + +dynamic metNNextForecastBlock(item) { + final data = item["data"]; + if (data == null) { + return null; + } + + for (final key in ["next_1_hours", "next_6_hours", "next_12_hours"]) { + final block = data[key]; + if (block != null && block["summary"] != null && block["details"] != null) { + return block; + } } + return null; } -Future MetNGetLocalTime(lat, lng) async { - /* - return await XWorldTime.timeByLocation( - latitude: lat, - longitude: lng, - ); - */ - final params = { - 'key': timezonedbKey, - 'lat': lat.toString(), - 'lng': lng.toString(), - 'format': 'json', - 'by': 'position' - }; - final url = Uri.https('api.timezonedb.com', 'v2.1/get-time-zone', params); - var file = await XCustomCacheManager.fetchData(url.toString(), "$lat, $lng timezonedb.com"); - var response = await file[0].readAsString(); - var body = jsonDecode(response); +String metNConditionFromJson(item) { + final summary = metNNextForecastBlock(item)?["summary"]; + final symbolCode = summary == null ? null : summary["symbol_code"]; - return DateTime.parse(body["formatted"]); + if (symbolCode == null) { + return 'Clear Sky'; + } + return metNTextCorrection(symbolCode); } -Future> MetNMakeRequest(double lat, double lng, String real_loc) async { +double metNPrecipAmountFromJson(item) { + final details = metNNextForecastBlock(item)?["details"]; + final precip = details == null ? null : details["precipitation_amount"]; + + if (precip is num) { + return precip.toDouble(); + } + return 0.0; +} + +int? metNPrecipProbabilityFromJson(item) { + final details = metNNextForecastBlock(item)?["details"]; + final probability = + details == null ? null : details["probability_of_precipitation"]; + + if (probability is num) { + return probability.round(); + } + return null; +} +Future> MetNMakeRequest( + double lat, double lng, String real_loc) async { final MnParams = { - "lat" : lat.toString(), - "lon" : lng.toString(), - "altitude" : "100", + "lat": lat.toString(), + "lon": lng.toString(), + "altitude": "100", }; final headers = { "User-Agent": "Overmorrow weather (com.marotidev.overmorrow)" }; - final MnUrl = Uri.https("api.met.no", 'weatherapi/locationforecast/2.0/complete', MnParams); + final MnUrl = Uri.https( + "api.met.no", 'weatherapi/locationforecast/2.0/complete', MnParams); - var MnFile = await XCustomCacheManager.fetchData(MnUrl.toString(), "$real_loc, met.no", headers: headers); + var MnFile = await XCustomCacheManager.fetchData( + MnUrl.toString(), "$real_loc, met.no", + headers: headers); var MnResponse = await MnFile[0].readAsString(); bool isonline = MnFile[1]; @@ -123,27 +152,31 @@ Future> MetNMakeRequest(double lat, double lng, String real_loc) a DateTime fetch_datetime = await MnFile[0].lastModified(); return [MnData, fetch_datetime, isonline]; - } -WeatherCurrent metNWeatherCurrentFromJson(item, ) { - var it = item["properties"]["timeseries"][0]["data"]; +WeatherCurrent metNWeatherCurrentFromJson( + item, +) { + final firstTimeseries = item["properties"]["timeseries"][0]; + var it = firstTimeseries["data"]; return WeatherCurrent( - condition: metNTextCorrection(it["next_1_hours"]["summary"]["symbol_code"],), - precipMm: it["next_1_hours"]["details"]["precipitation_amount"], + condition: metNConditionFromJson(firstTimeseries), + precipMm: metNPrecipAmountFromJson(firstTimeseries), tempC: it["instant"]["details"]["air_temperature"], humidity: it["instant"]["details"]["relative_humidity"].round(), windKmh: it["instant"]["details"]["wind_speed"] * 3.6, uv: it["instant"]["details"]["ultraviolet_index_clear_sky"].round(), - feelsLikeC: metNcalculateFeelsLike(it["instant"]["details"]["air_temperature"], - it["instant"]["details"]["relative_humidity"], it["instant"]["details"]["wind_speed"] * 3.6), + feelsLikeC: metNcalculateFeelsLike( + it["instant"]["details"]["air_temperature"], + it["instant"]["details"]["relative_humidity"], + it["instant"]["details"]["wind_speed"] * 3.6), windDirA: it["instant"]["details"]["wind_from_direction"].round(), - ); } -WeatherDay metNWeatherDayFromJson(item, start, end, index, hourDif) { +WeatherDay metNWeatherDayFromJson( + item, start, end, index, double lat, double lng) { List rawTemps = []; List windspeeds = []; List winddirs = []; @@ -153,14 +186,28 @@ WeatherDay metNWeatherDayFromJson(item, start, end, index, hourDif) { int? precipProb; List oneSummary = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - const weather_names = ['Clear Night', 'Partly Cloudy', 'Clear Sky', 'Overcast', - 'Haze', 'Rain', 'Sleet', 'Drizzle', 'Thunderstorm', 'Heavy Snow', 'Fog', 'Snow', - 'Heavy Rain', 'Cloudy Night']; + const weather_names = [ + 'Clear Night', + 'Partly Cloudy', + 'Clear Sky', + 'Overcast', + 'Haze', + 'Rain', + 'Sleet', + 'Drizzle', + 'Thunderstorm', + 'Heavy Snow', + 'Fog', + 'Snow', + 'Heavy Rain', + 'Cloudy Night' + ]; List hours = []; for (int n = start; n < end; n++) { - WeatherHour hour = metNWeatherHourFromJson(item["properties"]["timeseries"][n], hourDif); + WeatherHour hour = + metNWeatherHourFromJson(item["properties"]["timeseries"][n], lat, lng); rawTemps.add(hour.tempC); windspeeds.add(hour.windKmh); winddirs.add(hour.windDirA); @@ -185,66 +232,72 @@ WeatherDay metNWeatherDayFromJson(item, start, end, index, hourDif) { int BIndex = oneSummary.indexOf(largest_value); return WeatherDay( - totalPrecipMm: precip.reduce((a, b) => a + b), - precipProb: precipProb, - minTempC: rawTemps.reduce(min), - maxTempC: rawTemps.reduce(max), - hourly: hours, - windKmh: (windspeeds.reduce((a, b) => a + b) / windspeeds.length), - date: DateTime.parse(item["properties"]["timeseries"][start]["time"]).add(Duration(hours: -hourDif)), - condition: weather_names[BIndex], - windDirA: (winddirs.whereType().reduce((a, b) => a + b) / winddirs.whereType().length).round(), - uv: uv, + totalPrecipMm: precip.reduce((a, b) => a + b), + precipProb: precipProb, + minTempC: rawTemps.reduce(min), + maxTempC: rawTemps.reduce(max), + hourly: hours, + windKmh: (windspeeds.reduce((a, b) => a + b) / windspeeds.length), + date: metNLocalTimeFromJson( + item["properties"]["timeseries"][start], lat, lng), + condition: weather_names[BIndex], + windDirA: (winddirs.whereType().reduce((a, b) => a + b) / + winddirs.whereType().length) + .round(), + uv: uv, ); } -WeatherHour metNWeatherHourFromJson(item, hourDif) { - var nextHours = item["data"]["next_1_hours"] ?? item["data"]["next_6_hours"]; - +WeatherHour metNWeatherHourFromJson(item, double lat, double lng) { return WeatherHour( windGustKmh: null, - condition: metNTextCorrection(nextHours["summary"]["symbol_code"]), + condition: metNConditionFromJson(item), tempC: item["data"]["instant"]["details"]["air_temperature"], - precipMm: nextHours["details"]["precipitation_amount"], - precipProb: nextHours["details"]["probability_of_precipitation"]?.round(), - time: DateTime.parse(item["time"]).add(Duration(hours: -hourDif)), + precipMm: metNPrecipAmountFromJson(item), + precipProb: metNPrecipProbabilityFromJson(item), + time: metNLocalTimeFromJson(item, lat, lng), windKmh: item["data"]["instant"]["details"]["wind_speed"] * 3.6, - windDirA: item["data"]["instant"]["details"]["wind_from_direction"]?.round(), - uv: item["data"]["instant"]["details"]["ultraviolet_index_clear_sky"]?.round(), + windDirA: + item["data"]["instant"]["details"]["wind_from_direction"]?.round(), + uv: item["data"]["instant"]["details"]["ultraviolet_index_clear_sky"] + ?.round(), ); } -Future metNGetWeatherSunStatus(item, lat, lng, int dif, DateTime timeThere, DateTime fetchDate) async { +Future metNGetWeatherSunStatus( + item, double lat, double lng, DateTime timeThere) async { + final date = + "${timeThere.year}-${timeThere.month.toString().padLeft(2, "0")}-${timeThere.day.toString().padLeft(2, "0")}"; final MnParams = { - "lat" : lat.toString(), - "lon" : lng.toString(), - "date" : "${fetchDate.year}-${fetchDate.month.toString().padLeft(2, "0")}-${fetchDate.day.toString().padLeft(2, "0")}", + "lat": lat.toString(), + "lon": lng.toString(), + "date": date, }; final headers = { "User-Agent": "Overmorrow weather (com.marotidev.overmorrow)" }; final MnUrl = Uri.https("api.met.no", 'weatherapi/sunrise/3.0/sun', MnParams); - var MnFile = await XCustomCacheManager.fetchData(MnUrl.toString(), "$lat, $lng met.no aqi", headers: headers); + var MnFile = await XCustomCacheManager.fetchData( + MnUrl.toString(), "$lat, $lng $date met.no sun", + headers: headers); var MnResponse = await MnFile[0].readAsString(); final item = jsonDecode(MnResponse); - List sunriseString = item["properties"]["sunrise"]["time"].split("T")[1].split("+")[0].split(":"); - DateTime sunrise = timeThere.copyWith( - hour: (int.parse(sunriseString[0]) - dif) % 24, - minute: int.parse(sunriseString[1]), - ); - - List sunsetString = item["properties"]["sunset"]["time"].split("T")[1].split("+")[0].split(":"); - DateTime sunset = timeThere.copyWith( - hour: (int.parse(sunsetString[0]) - dif) % 24, - minute: int.parse(sunsetString[1]), - ); + DateTime sunrise = TimezoneService.localDateTimeFromUtc( + lat, lng, DateTime.parse(item["properties"]["sunrise"]["time"])); + DateTime sunset = TimezoneService.localDateTimeFromUtc( + lat, lng, DateTime.parse(item["properties"]["sunset"]["time"])); return WeatherSunStatus( sunrise: sunrise, sunset: sunset, - sunstatus: min(max(timeThere.difference(sunrise).inMinutes / sunset.difference(sunrise).inMinutes, 0), 1), + sunstatus: min( + max( + timeThere.difference(sunrise).inMinutes / + sunset.difference(sunrise).inMinutes, + 0), + 1), ); } @@ -260,8 +313,10 @@ WeatherRain15Minutes metNWeatherRain15MinutesFromJson(item) { List precips = []; List hourly = []; - for (int i = 0; i < 6; i++) { - double x = double.parse(item["properties"]["timeseries"][i]["data"]["next_1_hours"]["details"]["precipitation_amount"].toStringAsFixed(1)); + for (int i = 0; i < min(item["properties"]["timeseries"].length, 6); i++) { + double x = double.parse( + metNPrecipAmountFromJson(item["properties"]["timeseries"][i]) + .toStringAsFixed(1)); if (x > 0.0) { if (closest == 100) { @@ -283,7 +338,8 @@ WeatherRain15Minutes metNWeatherRain15MinutesFromJson(item) { double dif = next - now; for (double x = 0; x <= 1; x += 0.25) { - double g = (now + dif * x) / 4; //because we are dividing the sum of 1 hour into quarters + double g = (now + dif * x) / + 4; //because we are dividing the sum of 1 hour into quarters sum += g; precips.add(g); } @@ -295,16 +351,13 @@ WeatherRain15Minutes metNWeatherRain15MinutesFromJson(item) { if (closest <= 2) { if (end <= 1) { text = "rainInOneHour"; - } - else { + } else { text = "rainInHours"; time = end; } - } - else if (closest < 1) { + } else if (closest < 1) { text = "rainExpectedInOneHour"; - } - else { + } else { text = "rainExpectedInHours"; time = closest; } @@ -318,38 +371,36 @@ WeatherRain15Minutes metNWeatherRain15MinutesFromJson(item) { precipSumMm: sum, precipListMm: precips, ); - } Future MetNGetWeatherData(lat, lng, placeName) async { + final double latitude = (lat as num).toDouble(); + final double longitude = (lng as num).toDouble(); - var Mn = await MetNMakeRequest(lat, lng, placeName); + var Mn = await MetNMakeRequest(latitude, longitude, placeName); var MnBody = Mn[0]; - DateTime lastKnowTime = await MetNGetLocalTime(lat, lng); + DateTime localTime = TimezoneService.getLocalTime(latitude, longitude); DateTime fetch_datetime = Mn[1]; - //this gives us the time passed since last fetch, this is all basically for offline mode - Duration realTimeOffset = DateTime.now().difference(fetch_datetime); - - //now we just need to apply this time offset to get the real current time - DateTime localTime = lastKnowTime.add(realTimeOffset); - - int hourDif = metNCalculateHourDif(localTime); - bool isonline = Mn[2]; //removes the outdated hours - int start = localTime.difference(DateTime(lastKnowTime.year, lastKnowTime.month, - lastKnowTime.day, lastKnowTime.hour)).inHours; + DateTime approximateLocal = + DateTime(localTime.year, localTime.month, localTime.day, localTime.hour); + int start = (MnBody["properties"]["timeseries"] as List).indexWhere((item) { + final itemLocalTime = metNLocalTimeFromJson(item, latitude, longitude); + return !itemLocalTime.isBefore(approximateLocal); + }); //make sure that there is data left - if (start >= MnBody["properties"]["timeseries"].length) { + if (start < 0 || start >= MnBody["properties"]["timeseries"].length) { throw const SocketException("Cached data expired"); } //remove outdated hours - MnBody["properties"]["timeseries"] = MnBody["properties"]["timeseries"].sublist(start); + MnBody["properties"]["timeseries"] = + MnBody["properties"]["timeseries"].sublist(start); List days = []; List hourly72 = []; @@ -357,11 +408,14 @@ Future MetNGetWeatherData(lat, lng, placeName) async { int begin = 0; int index = 0; - int previous_hour = 0; - for (int n = 0; n < MnBody["properties"]["timeseries"].length; n++) { - int hour = (int.parse(MnBody["properties"]["timeseries"][n]["time"].split("T")[1].split(":")[0]) - hourDif) % 24; - if (n > 0 && hour - previous_hour < 1) { - WeatherDay day = metNWeatherDayFromJson(MnBody, begin, n, index, hourDif); + DateTime previousLocalTime = metNLocalTimeFromJson( + MnBody["properties"]["timeseries"][0], latitude, longitude); + for (int n = 1; n < MnBody["properties"]["timeseries"].length; n++) { + final localForecastTime = metNLocalTimeFromJson( + MnBody["properties"]["timeseries"][n], latitude, longitude); + if (!metNIsSameLocalDate(localForecastTime, previousLocalTime)) { + WeatherDay day = + metNWeatherDayFromJson(MnBody, begin, n, index, latitude, longitude); days.add(day); if (hourly72.length < 72) { @@ -375,30 +429,38 @@ Future MetNGetWeatherData(lat, lng, placeName) async { index += 1; begin = n; } - previous_hour = hour; + previousLocalTime = localForecastTime; } - return WeatherData( - provider: "met norway", + if (begin < MnBody["properties"]["timeseries"].length) { + WeatherDay day = metNWeatherDayFromJson(MnBody, begin, + MnBody["properties"]["timeseries"].length, index, latitude, longitude); + days.add(day); - lat: lat, - lng: lng, + if (hourly72.length < 72) { + for (int z = 0; z < day.hourly.length; z++) { + if (hourly72.length < 72) { + hourly72.add(day.hourly[z]); + } + } + } + } + return WeatherData( + provider: "met norway", + lat: latitude, + lng: longitude, place: placeName, - radar: await RainviewerRadar.getData(), - aqi: await oMGetWeatherAqi(lat, lng), - sunStatus: await metNGetWeatherSunStatus(MnBody, lat, lng, hourDif, localTime, fetch_datetime), + aqi: await oMGetWeatherAqi(latitude, longitude), + sunStatus: + await metNGetWeatherSunStatus(MnBody, latitude, longitude, localTime), alerts: [], minutely15Precip: metNWeatherRain15MinutesFromJson(MnBody), - current: metNWeatherCurrentFromJson(MnBody), days: days, - dailyMinMaxTemp: weatherGetMaxMinTempForDaily(days), - hourly72: hourly72, - fetchDatetime: fetch_datetime, updatedTime: DateTime.now(), localTime: localTime, @@ -408,56 +470,75 @@ Future MetNGetWeatherData(lat, lng, placeName) async { Future metNGetLightResponse(lat, lon, {bool isCompact = true}) async { final params = { - "lat" : lat.toString(), - "lon" : lon.toString(), - "altitude" : "100", + "lat": lat.toString(), + "lon": lon.toString(), + "altitude": "100", }; final headers = { "User-Agent": "Overmorrow weather (com.marotidev.overmorrow)" }; - final url = Uri.https("api.met.no", 'weatherapi/locationforecast/2.0/${isCompact ? "compact" : "complete"}', params); + final url = Uri.https( + "api.met.no", + 'weatherapi/locationforecast/2.0/${isCompact ? "compact" : "complete"}', + params); final response = (await http.get(url, headers: headers)).body; return jsonDecode(response); } -Future metNGetLightCurrentData(placeName, lat, lon, SharedPreferences prefs) async { +Future metNGetLightCurrentData( + placeName, lat, lon, SharedPreferences prefs) async { final item = await metNGetLightResponse(lat, lon); DateTime now = DateTime.now(); + final firstTimeseries = item["properties"]["timeseries"][0]; return LightCurrentWeatherData( - condition: metNTextCorrection(item["properties"]["timeseries"][0]["data"]["next_1_hours"]["summary"]["symbol_code"]), + condition: metNConditionFromJson(firstTimeseries), place: placeName, temp: unitConversion( - item["properties"]["timeseries"][0]["data"]["instant"]["details"]["air_temperature"], - prefs.getString("Temperature") ?? "˚C").round(), + item["properties"]["timeseries"][0]["data"]["instant"]["details"] + ["air_temperature"], + prefs.getString("Temperature") ?? "˚C") + .round(), updatedTime: "${now.hour}:${now.minute.toString().padLeft(2, "0")}", dateString: getDateStringFromLocalTime(now), ); } -Future metNGetLightWindData(lat, lon, SharedPreferences prefs) async { +Future metNGetLightWindData( + lat, lon, SharedPreferences prefs) async { final item = await metNGetLightResponse(lat, lon); return LightWindData( - windDirAngle: item["properties"]["timeseries"][0]["data"]["instant"]["details"]["wind_from_direction"].round(), - windSpeed: unitConversion(item["properties"]["timeseries"][0]["data"]["instant"]["details"]["wind_speed"] * 3.6, prefs.getString("Wind") ?? "m/s").round(), + windDirAngle: item["properties"]["timeseries"][0]["data"]["instant"] + ["details"]["wind_from_direction"] + .round(), + windSpeed: unitConversion( + item["properties"]["timeseries"][0]["data"]["instant"]["details"] + ["wind_speed"] * + 3.6, + prefs.getString("Wind") ?? "m/s") + .round(), windUnit: prefs.getString("Wind") ?? "m/s", ); } -Future metNGetLightUvData(lat, lon, SharedPreferences prefs) async { +Future metNGetLightUvData( + lat, lon, SharedPreferences prefs) async { final item = await metNGetLightResponse(lat, lon, isCompact: false); return LightUvData( - uv: item["properties"]["timeseries"][0]["data"]["instant"]["details"]["ultraviolet_index_clear_sky"].round(), + uv: item["properties"]["timeseries"][0]["data"]["instant"]["details"] + ["ultraviolet_index_clear_sky"] + .round(), ); } -Future metNGetLightHourlyData(placeName, lat, lon, SharedPreferences prefs) async { +Future metNGetLightHourlyData( + placeName, lat, lon, SharedPreferences prefs) async { final item = await metNGetLightResponse(lat, lon); List hourly6Conditions = []; @@ -479,28 +560,35 @@ Future metNGetLightHourlyData(placeName, lat, lon, Shar DateTime d = DateTime.parse(hour["time"]).toLocal(); if (d.hour % 6 == 0) { - hourly6Conditions.add(metNTextCorrection( - hour["data"]["next_1_hours"]["summary"]["symbol_code"])); - hourly6Temps.add(unitConversion( - hour["data"]["instant"]["details"]["air_temperature"],tempUnit).round(),); + hourly6Conditions.add(metNConditionFromJson(hour)); + hourly6Temps.add( + unitConversion( + hour["data"]["instant"]["details"]["air_temperature"], tempUnit) + .round(), + ); hourly6Names.add(formatHourByTimeMode(d, timeMode)); } if (i < 4) { - hourly1Conditions.add(metNTextCorrection( - hour["data"]["next_1_hours"]["summary"]["symbol_code"])); - hourly1Temps.add(unitConversion( - hour["data"]["instant"]["details"]["air_temperature"],tempUnit).round(),); + hourly1Conditions.add(metNConditionFromJson(hour)); + hourly1Temps.add( + unitConversion( + hour["data"]["instant"]["details"]["air_temperature"], tempUnit) + .round(), + ); hourly1Names.add(formatHourByTimeMode(d, timeMode)); } } return LightHourlyForecastData( place: placeName, - currentCondition: metNTextCorrection(item["properties"]["timeseries"][0]["data"]["next_1_hours"]["summary"]["symbol_code"]), + currentCondition: + metNConditionFromJson(item["properties"]["timeseries"][0]), currentTemp: unitConversion( - item["properties"]["timeseries"][0]["data"]["instant"]["details"]["air_temperature"], - tempUnit).round(), + item["properties"]["timeseries"][0]["data"]["instant"]["details"] + ["air_temperature"], + tempUnit) + .round(), updatedTime: "${now.hour}:${now.minute.toString().padLeft(2, "0")}", //i can't sync lists to widgets so i need to encode and then decode them hourly6Conditions: jsonEncode(hourly6Conditions), @@ -511,4 +599,4 @@ Future metNGetLightHourlyData(placeName, lat, lon, Shar hourly1Names: jsonEncode(hourly1Names), hourly1Temps: jsonEncode(hourly1Temps), ); -} \ No newline at end of file +} diff --git a/lib/decoders/decode_wapi.dart b/lib/decoders/decode_wapi.dart index 055eb5c7..9d10d897 100644 --- a/lib/decoders/decode_wapi.dart +++ b/lib/decoders/decode_wapi.dart @@ -28,12 +28,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../api_key.dart'; import '../services/caching_service.dart'; +import '../services/timezone_service.dart'; import '../weather_refact.dart' as weather_refactor; import 'decode_RV.dart'; import 'weather_data.dart'; - Future> WapiMakeRequest(String latlong, String real_loc) async { //gets the json response for weatherapi.com final params = { @@ -45,7 +45,8 @@ Future> WapiMakeRequest(String latlong, String real_loc) async { }; final url = Uri.https('api.weatherapi.com', 'v1/forecast.json', params); - var file = await XCustomCacheManager.fetchData(url.toString(), "$real_loc, weatherapi.com"); + var file = await XCustomCacheManager.fetchData( + url.toString(), "$real_loc, weatherapi.com"); DateTime fetch_datetime = await file[0].lastModified(); bool isonline = file[1]; @@ -66,8 +67,8 @@ int wapiGetWindDir(var data) { return (total / data.length).round(); } - -double getSunStatus(String sunrise, String sunset, DateTime localtime, {by = " "}) { +double getSunStatus(String sunrise, String sunset, DateTime localtime, + {by = " "}) { List splited1 = sunrise.split(by); List num1 = splited1[0].split(":"); int hour1 = int.parse(num1[0]); @@ -94,53 +95,38 @@ double getSunStatus(String sunrise, String sunset, DateTime localtime, {by = " " } Future WapiGetLocalTime(lat, lng) async { - final params = { - 'key': timezonedbKey, - 'lat': lat.toString(), - 'lng': lng.toString(), - 'format': 'json', - 'by': 'position' - }; - final url = Uri.https('api.timezonedb.com', 'v2.1/get-time-zone', params); - var file = await XCustomCacheManager.fetchData(url.toString(), "$lat, $lng timezonedb.com"); - var response = await file[0].readAsString(); - var body = jsonDecode(response); - - return DateTime.parse(body["formatted"]); + return TimezoneService.getLocalTime( + (lat as num).toDouble(), (lng as num).toDouble()); } String wapiTextCorrection(name, isday) { String x = weather_refactor.weatherTextMap[name] ?? 'Clear Sky'; - if (x == 'Clear Sky'){ + if (x == 'Clear Sky') { if (isday == 1) { - x = 'Clear Sky'; + x = 'Clear Sky'; + } else { + x = 'Clear Night'; } - else{ - x = 'Clear Night'; - } - } - else if (x == 'Partly Cloudy'){ + } else if (x == 'Partly Cloudy') { if (isday == 1) { - x = 'Partly Cloudy'; - } - else{ - x = 'Cloudy Night'; + x = 'Partly Cloudy'; + } else { + x = 'Cloudy Night'; } } return x; } - //---------------------------------Weather Classes-------------------------------- WeatherCurrent wapiWeatherCurrentFromJson(item, start) { return WeatherCurrent( condition: wapiTextCorrection( - item["hour"][start]["condition"]["code"], item["hour"][start]["is_day"], + item["hour"][start]["condition"]["code"], + item["hour"][start]["is_day"], ), tempC: item["hour"][start]["temp_c"], feelsLikeC: item["hour"][start]["feelslike_c"], - uv: item["hour"][start]["uv"].round(), humidity: item["hour"][start]["humidity"], precipMm: item["day"]["totalprecip_mm"], @@ -153,13 +139,11 @@ WeatherDay wapiWeatherDayFromJson(item, approximatelocal) { return WeatherDay( condition: wapiTextCorrection(item["day"]["condition"]["code"], 1), date: DateTime.parse(item["date"]), - minTempC: item["day"]["mintemp_c"], maxTempC: item["day"]["maxtemp_c"], - hourly: wapiBuildWeatherHourList(item["hour"], approximatelocal), - - totalPrecipMm: item["day"]["totalprecip_mm"] + item["day"]["totalsnow_cm"] / 10, + totalPrecipMm: + item["day"]["totalprecip_mm"] + item["day"]["totalsnow_cm"] / 10, precipProb: item["day"]["daily_chance_of_rain"], windKmh: item["day"]["maxwind_kph"], uv: item["day"]["uv"].round(), @@ -185,10 +169,8 @@ WeatherHour wapiWeatherHourFromJson(item, approximatelocal) { tempC: item["temp_c"], time: DateTime.parse(item["time"]), precipMm: item["precip_mm"] + (item["snow_cm"] / 10), - windKmh: item["wind_kph"], windGustKmh: item["gust_kph"], - precipProb: max(item["chance_of_rain"], item["chance_of_snow"]), uv: item["uv"].round(), windDirA: item["wind_degree"], @@ -197,10 +179,14 @@ WeatherHour wapiWeatherHourFromJson(item, approximatelocal) { WeatherSunStatus wapiWeatherSunStatusFromJson(item, localtime) { return WeatherSunStatus( - sunrise: DateFormat('h:mm a').parse(item["forecast"]["forecastday"][0]["astro"]["sunrise"]), - sunset: DateFormat('h:mm a').parse(item["forecast"]["forecastday"][0]["astro"]["sunset"]), - sunstatus: getSunStatus(item["forecast"]["forecastday"][0]["astro"]["sunrise"], - item["forecast"]["forecastday"][0]["astro"]["sunset"], localtime), + sunrise: DateFormat('h:mm a') + .parse(item["forecast"]["forecastday"][0]["astro"]["sunrise"]), + sunset: DateFormat('h:mm a') + .parse(item["forecast"]["forecastday"][0]["astro"]["sunset"]), + sunstatus: getSunStatus( + item["forecast"]["forecastday"][0]["astro"]["sunrise"], + item["forecast"]["forecastday"][0]["astro"]["sunset"], + localtime), ); } @@ -210,7 +196,6 @@ WeatherAqi wapiWeatherAqiFromJson(item) { ); } - List wapiGetWeatherAlerts(item) { final List alerts = []; final alertList = item["alerts"]["alert"]; @@ -241,7 +226,6 @@ WeatherAlert wapiWeatherAlertFromJson(item) { } WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { - //weatherapi doesn't actaully have 15 minute forecast(well it does but it's paid), but i figured i could just use the //hourly data and just use some smoothing between the hours to emulate the 15 minutes //still better than not having it @@ -266,9 +250,10 @@ WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { double x; if (hour == 0 && day == 0) { x = double.parse(item["current"]["precip_mm"].toStringAsFixed(1)); - } - else { - x = double.parse(item["forecast"]["forecastday"][day]["hour"][hour]["precip_mm"].toStringAsFixed(1)); + } else { + x = double.parse(item["forecast"]["forecastday"][day]["hour"][hour] + ["precip_mm"] + .toStringAsFixed(1)); } if (x > 0.0) { @@ -284,8 +269,7 @@ WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { i += 1; hour += 1; - } - else { + } else { day += 1; } } @@ -298,7 +282,8 @@ WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { double dif = next - now; for (double x = 0; x <= 1; x += 0.25) { - double g = (now + (dif * x)) / 4; //because we are dividing the sum of 1 hour into quarters + double g = (now + (dif * x)) / + 4; //because we are dividing the sum of 1 hour into quarters sum += g; precips.add(g); } @@ -310,16 +295,13 @@ WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { if (closest <= 2) { if (end <= 1) { text = "rainInOneHour"; - } - else { + } else { text = "rainInHours"; time = end; } - } - else if (closest < 1) { + } else if (closest < 1) { text = "rainExpectedInOneHour"; - } - else { + } else { text = "rainExpectedInHours"; time = closest; } @@ -333,35 +315,30 @@ WeatherRain15Minutes wapiWeatherRain15MinutesFromJson(item, day, hour) { precipSumMm: sum, precipListMm: precips, ); - } - Future WapiGetWeatherData(lat, lng, placeName) async { - var wapi = await WapiMakeRequest("$lat,$lng", placeName); var wapi_body = wapi[0]; DateTime fetch_datetime = wapi[1]; bool isonline = wapi[2]; - //DateTime lastKnowTime = DateTime.parse(wapi_body["location"]["localtime"]); - DateTime lastKnowTime = await WapiGetLocalTime(lat, lng); - - //this gives us the time passed since last fetch, this is all basically for offline mode - Duration realTimeOffset = DateTime.now().difference(fetch_datetime); - - //now we just need to apply this time offset to get the real current time - DateTime localtime = lastKnowTime.add(realTimeOffset); + DateTime localtime = await WapiGetLocalTime(lat, lng); + DateTime firstForecastDate = + DateTime.parse(wapi_body["forecast"]["forecastday"][0]["date"]); //get hour diff - DateTime approximateLocal = DateTime(localtime.year, localtime.month, localtime.day, localtime.hour); - int start = approximateLocal.difference(DateTime(lastKnowTime.year, - lastKnowTime.month, lastKnowTime.day)).inHours % 24; + DateTime approximateLocal = + DateTime(localtime.year, localtime.month, localtime.day, localtime.hour); + int start = approximateLocal.hour; //get day diff - int dayDif = DateTime(localtime.year, localtime.month, localtime.day).difference( - DateTime(lastKnowTime.year, lastKnowTime.month, lastKnowTime.day)).inDays; + int dayDif = max( + DateTime(localtime.year, localtime.month, localtime.day) + .difference(firstForecastDate) + .inDays, + 0); //make sure that there is data left if (dayDif >= wapi_body["forecast"]["forecastday"].length) { @@ -369,7 +346,8 @@ Future WapiGetWeatherData(lat, lng, placeName) async { } //remove outdated days - wapi_body["forecast"]["forecastday"] = wapi_body["forecast"]["forecastday"].sublist(dayDif); + wapi_body["forecast"]["forecastday"] = + wapi_body["forecast"]["forecastday"].sublist(dayDif); List days = []; List hourly72 = []; @@ -389,32 +367,29 @@ Future WapiGetWeatherData(lat, lng, placeName) async { } return WeatherData( - provider: "weatherapi.com", - - place: placeName, - lat: lat, - lng: lng, - - hourly72: hourly72, - - current: wapiWeatherCurrentFromJson(wapi_body["forecast"]["forecastday"][0], start,), - days: days, - sunStatus: wapiWeatherSunStatusFromJson(wapi_body, - DateTime(localtime.year, localtime.month, localtime.day, localtime.hour, localtime.minute)), - aqi: wapiWeatherAqiFromJson(wapi_body), - radar: await RainviewerRadar.getData(), - - dailyMinMaxTemp: weatherGetMaxMinTempForDaily(days), - - fetchDatetime: fetch_datetime, - updatedTime: DateTime.now(), - localTime: localtime, - - minutely15Precip: wapiWeatherRain15MinutesFromJson(wapi_body, 0, start), - alerts: wapiGetWeatherAlerts(wapi_body), - - isOnline: isonline - ); + provider: "weatherapi.com", + place: placeName, + lat: lat, + lng: lng, + hourly72: hourly72, + current: wapiWeatherCurrentFromJson( + wapi_body["forecast"]["forecastday"][0], + start, + ), + days: days, + sunStatus: wapiWeatherSunStatusFromJson( + wapi_body, + DateTime(localtime.year, localtime.month, localtime.day, + localtime.hour, localtime.minute)), + aqi: wapiWeatherAqiFromJson(wapi_body), + radar: await RainviewerRadar.getData(), + dailyMinMaxTemp: weatherGetMaxMinTempForDaily(days), + fetchDatetime: fetch_datetime, + updatedTime: DateTime.now(), + localTime: localtime, + minutely15Precip: wapiWeatherRain15MinutesFromJson(wapi_body, 0, start), + alerts: wapiGetWeatherAlerts(wapi_body), + isOnline: isonline); } Future wapiGetCurrentResponse(lat, lon) async { @@ -431,39 +406,46 @@ Future wapiGetCurrentResponse(lat, lon) async { return jsonDecode(response); } -Future wapiGetLightCurrentData(placeName, lat, lon, SharedPreferences prefs) async { +Future wapiGetLightCurrentData( + placeName, lat, lon, SharedPreferences prefs) async { final item = await wapiGetCurrentResponse(lat, lon); DateTime now = DateTime.now(); return LightCurrentWeatherData( - condition: wapiTextCorrection(item["current"]["condition"]["code"], item["current"]["is_day"]), + condition: wapiTextCorrection( + item["current"]["condition"]["code"], item["current"]["is_day"]), place: placeName, - temp: unitConversion(item["current"]["temp_c"], prefs.getString("Temperature") ?? "˚C").round(), + temp: unitConversion( + item["current"]["temp_c"], prefs.getString("Temperature") ?? "˚C") + .round(), updatedTime: "${now.hour}:${now.minute.toString().padLeft(2, "0")}", dateString: getDateStringFromLocalTime(now), ); } -Future wapiGetLightWindData(lat, lon, SharedPreferences prefs) async { +Future wapiGetLightWindData( + lat, lon, SharedPreferences prefs) async { final item = await wapiGetCurrentResponse(lat, lon); return LightWindData( - windDirAngle: item["current"]["wind_degree"], - windSpeed: unitConversion(item["current"]["wind_kph"], prefs.getString("Wind") ?? "m/s").round(), - windUnit: prefs.getString("Wind") ?? "m/s", + windDirAngle: item["current"]["wind_degree"], + windSpeed: unitConversion( + item["current"]["wind_kph"], prefs.getString("Wind") ?? "m/s") + .round(), + windUnit: prefs.getString("Wind") ?? "m/s", ); } -Future wapiGetLightUvData(lat, lon, SharedPreferences prefs) async { +Future wapiGetLightUvData( + lat, lon, SharedPreferences prefs) async { final item = await wapiGetCurrentResponse(lat, lon); - return LightUvData( - uv: item["current"]["uv"].round() - ); + return LightUvData(uv: item["current"]["uv"].round()); } -Future wapiGetLightHourlyData(placeName, lat, lon, SharedPreferences prefs) async { +Future wapiGetLightHourlyData( + placeName, lat, lon, SharedPreferences prefs) async { final params = { 'key': wapi_Key, 'q': "$lat, $lon", @@ -492,16 +474,20 @@ Future wapiGetLightHourlyData(placeName, lat, lon, Shar for (int i = 0; i < item["forecast"]["forecastday"][0]["hour"].length; i++) { final hour = item["forecast"]["forecastday"][0]["hour"][i]; - DateTime d = DateTime.fromMillisecondsSinceEpoch(hour["time_epoch"] * 1000, isUtc: true).toLocal(); + DateTime d = DateTime.fromMillisecondsSinceEpoch(hour["time_epoch"] * 1000, + isUtc: true) + .toLocal(); if (d.hour % 6 == 0) { - hourly6Conditions.add(wapiTextCorrection(hour["condition"]["code"], hour["is_day"])); + hourly6Conditions + .add(wapiTextCorrection(hour["condition"]["code"], hour["is_day"])); hourly6Temps.add(unitConversion(hour["temp_c"], tempUnit).round()); hourly6Names.add(formatHourByTimeMode(d, timeMode)); } if (d.difference(now).inHours >= 0 && d.difference(now).inHours < 3) { - hourly1Conditions.add(wapiTextCorrection(hour["condition"]["code"], hour["is_day"])); + hourly1Conditions + .add(wapiTextCorrection(hour["condition"]["code"], hour["is_day"])); hourly1Temps.add(unitConversion(hour["temp_c"], tempUnit).round()); hourly1Names.add(formatHourByTimeMode(d, timeMode)); } @@ -509,7 +495,8 @@ Future wapiGetLightHourlyData(placeName, lat, lon, Shar return LightHourlyForecastData( place: placeName, - currentCondition: wapiTextCorrection(item["current"]["condition"]["code"], item["current"]["is_day"]), + currentCondition: wapiTextCorrection( + item["current"]["condition"]["code"], item["current"]["is_day"]), currentTemp: unitConversion(item["current"]["temp_c"], tempUnit).round(), updatedTime: "${now.hour}:${now.minute.toString().padLeft(2, "0")}", //i can't sync lists to widgets so i need to encode and then decode them diff --git a/lib/decoders/weather_data.dart b/lib/decoders/weather_data.dart index 7ef569c2..b05b7987 100644 --- a/lib/decoders/weather_data.dart +++ b/lib/decoders/weather_data.dart @@ -19,6 +19,7 @@ along with this program. If not, see . import 'dart:async'; import 'package:overmorrow/decoders/decode_OM.dart'; import 'package:overmorrow/decoders/decode_RV.dart'; +import 'package:overmorrow/decoders/decode_mf.dart'; import 'package:overmorrow/decoders/decode_mn.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'decode_wapi.dart'; @@ -226,6 +227,9 @@ class WeatherData { if (provider == 'weatherapi') { return WapiGetWeatherData(lat, lng, placeName); } + else if (provider == "meteo-france"){ + return MfGetWeatherData(lat, lng, placeName); + } else if (provider == "met-norway"){ return MetNGetWeatherData(lat, lng, placeName); } @@ -281,6 +285,8 @@ class LightCurrentWeatherData { switch (provider) { case "weatherapi": return wapiGetLightCurrentData(placeName, lat, lng, prefs); + case "meteo-france": + return mfGetLightCurrentData(placeName, lat, lng, prefs); case "met-norway": return metNGetLightCurrentData(placeName, lat, lng, prefs); default: @@ -309,6 +315,8 @@ class LightWindData { switch (provider) { case "weatherapi": return wapiGetLightWindData(lat, lon, prefs); + case "meteo-france": + return mfGetLightWindData(lat, lon, prefs); case "met-norway": return metNGetLightWindData(lat, lon, prefs); default: @@ -333,6 +341,8 @@ class LightUvData { switch (provider) { case "weatherapi": return wapiGetLightUvData(lat, lon, prefs); + case "meteo-france": + return mfGetLightUvData(lat, lon, prefs); case "met-norway": return metNGetLightUvData(lat, lon, prefs); default: @@ -379,10 +389,12 @@ class LightHourlyForecastData { switch (provider) { case "weatherapi": return wapiGetLightHourlyData(placeName, lat, lon, prefs); + case "meteo-france": + return mfGetLightHourlyData(placeName, lat, lon, prefs); case "met-norway": return metNGetLightHourlyData(placeName, lat, lon, prefs); default: return omGetHourlyForecast(placeName, lat, lon, prefs); } } -} \ No newline at end of file +} diff --git a/lib/main_ui.dart b/lib/main_ui.dart index e3ceaf31..136e87bf 100644 --- a/lib/main_ui.dart +++ b/lib/main_ui.dart @@ -39,7 +39,7 @@ import '../l10n/app_localizations.dart'; String sanitizeErrorMessage(String e) { String newStr = e.toString().replaceAll(wapi_Key, "WAPIKEY"); newStr = newStr.replaceAll(access_key, "UNSPLASHKEY"); - newStr = newStr.replaceAll(timezonedbKey, "TIMEZONEDBKEY"); + newStr = newStr.replaceAll("__Wj7dVSTjV9YGu1guveLyDq0g7S7TfTjaHBTPTpO0kj8__", "METEOFRANCETOKEN"); return newStr; } @@ -586,7 +586,7 @@ class ProviderSelector extends StatelessWidget { child: Icon(Icons.unfold_more, color: Theme.of(context).colorScheme.primary, size: 22,), ), value: context.select((SettingsProvider p) => p.getWeatherProvider), - items: ["weatherapi", "open-meteo", "met-norway"].map((item) { + items: ["weatherapi", "open-meteo", "met-norway", "meteo-france"].map((item) { return DropdownMenuItem( value: item, child: Padding( diff --git a/lib/pages/settings_pages/about_page.dart b/lib/pages/settings_pages/about_page.dart index 5f02e70f..f88d92df 100644 --- a/lib/pages/settings_pages/about_page.dart +++ b/lib/pages/settings_pages/about_page.dart @@ -381,6 +381,16 @@ class ServicesPage extends StatelessWidget { style: TextStyle(color: Theme.of(context).colorScheme.tertiary, fontSize: 17, decoration: TextDecoration.underline,),), ), + const SizedBox(height: 10,), + GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + _launchUrl("https://meteofrance.com/"); + }, + child: Text("meteo-france", + style: TextStyle(color: Theme.of(context).colorScheme.tertiary, fontSize: 17, + decoration: TextDecoration.underline,),), + ), ], ), ), diff --git a/lib/pages/settings_pages/bg_updates_page.dart b/lib/pages/settings_pages/bg_updates_page.dart index 23b13375..e363701e 100644 --- a/lib/pages/settings_pages/bg_updates_page.dart +++ b/lib/pages/settings_pages/bg_updates_page.dart @@ -391,27 +391,34 @@ class _BackgroundUpdatesPageState extends State { child: Text(AppLocalizations.of(context)!.weatherProvderLowercase), ), - SegmentedButton( - multiSelectionEnabled: false, - segments: const [ - ButtonSegment( - value: "open-meteo", - label: Text('open-meteo'), - ), - ButtonSegment( - value: "weatherapi", - label: Text('weatherapi'), - ), - ButtonSegment( - value: "met-norway", - label: Text('met-norway'), - ), - ], - selected: {context.select((SettingsProvider p) => p.getOngoingNotificationProvider)}, - onSelectionChanged: (newSelection) { - HapticFeedback.lightImpact(); - context.read().setOngoingNotificationProvider(newSelection.first); - }, + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + multiSelectionEnabled: false, + segments: const [ + ButtonSegment( + value: "open-meteo", + label: Text('open-meteo'), + ), + ButtonSegment( + value: "weatherapi", + label: Text('weatherapi'), + ), + ButtonSegment( + value: "met-norway", + label: Text('met-norway'), + ), + ButtonSegment( + value: "meteo-france", + label: Text('meteo-france'), + ), + ], + selected: {context.select((SettingsProvider p) => p.getOngoingNotificationProvider)}, + onSelectionChanged: (newSelection) { + HapticFeedback.lightImpact(); + context.read().setOngoingNotificationProvider(newSelection.first); + }, + ), ), ], ) @@ -423,4 +430,4 @@ class _BackgroundUpdatesPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/settings_pages/settings_screens.dart b/lib/pages/settings_pages/settings_screens.dart index cf6d5425..7097d464 100644 --- a/lib/pages/settings_pages/settings_screens.dart +++ b/lib/pages/settings_pages/settings_screens.dart @@ -39,23 +39,28 @@ class AppearancePage extends StatelessWidget { @override Widget build(BuildContext context) { - String colorSource = context.select((ThemeProvider p) => p.getColorSource); - String customColorHex = context.select((ThemeProvider p) => p.getThemeSeedColorHex); + String customColorHex = + context.select((ThemeProvider p) => p.getThemeSeedColorHex); return Material( color: Theme.of(context).colorScheme.surface, child: CustomScrollView( slivers: [ SliverAppBar.large( - leading: - IconButton(icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary,), + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.primary, + ), onPressed: () { HapticFeedback.lightImpact(); Navigator.pop(context); }), - title: Text(AppLocalizations.of(context)!.appearance, - style: const TextStyle(fontSize: 30),), + title: Text( + AppLocalizations.of(context)!.appearance, + style: const TextStyle(fontSize: 30), + ), backgroundColor: Theme.of(context).colorScheme.surface, pinned: false, ), @@ -64,106 +69,138 @@ class AppearancePage extends StatelessWidget { padding: const EdgeInsets.only(left: 30, right: 30), child: AnimationLimiter( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: AnimationConfiguration.toStaggeredList( - duration: const Duration(milliseconds: 500), - childAnimationBuilder: (widget) => SlideAnimation( - horizontalOffset: 80.0, - child: FadeInAnimation( - child: widget, - ), - ), - children: [ - const Padding( - padding: EdgeInsets.only(left: 1, bottom: 14, top: 30), - child: Text("app theme", style: TextStyle(fontSize: 17),) - ), - - SegmentedButton( - selected: {context.watch().getBrightness}, - onSelectionChanged: (Set newSelection) { - HapticFeedback.mediumImpact(); - context.read().setBrightness(newSelection.first); - }, - segments: const [ - ButtonSegment( - icon: Icon(Icons.light_mode_outlined), - value: "light", - label: Text("light", style: TextStyle(fontSize: 18),), - ), - ButtonSegment( - icon: Icon(Icons.dark_mode_outlined), - value: "dark", - label: Text("dark", style: TextStyle(fontSize: 18),), - ), - ButtonSegment( - icon: Icon(Icons.brightness_6_outlined), - value: "auto", - label: Text("auto", style: TextStyle(fontSize: 18),), - ), - ], + crossAxisAlignment: CrossAxisAlignment.start, + children: AnimationConfiguration.toStaggeredList( + duration: const Duration(milliseconds: 500), + childAnimationBuilder: (widget) => SlideAnimation( + horizontalOffset: 80.0, + child: FadeInAnimation( + child: widget, + ), ), + children: [ + const Padding( + padding: + EdgeInsets.only(left: 1, bottom: 14, top: 30), + child: Text( + "app theme", + style: TextStyle(fontSize: 17), + )), + + SegmentedButton( + selected: { + context.watch().getBrightness + }, + onSelectionChanged: (Set newSelection) { + HapticFeedback.mediumImpact(); + context + .read() + .setBrightness(newSelection.first); + }, + segments: const [ + ButtonSegment( + icon: Icon(Icons.light_mode_outlined), + value: "light", + label: Text( + "light", + style: TextStyle(fontSize: 18), + ), + ), + ButtonSegment( + icon: Icon(Icons.dark_mode_outlined), + value: "dark", + label: Text( + "dark", + style: TextStyle(fontSize: 18), + ), + ), + ButtonSegment( + icon: Icon(Icons.brightness_6_outlined), + value: "auto", + label: Text( + "auto", + style: TextStyle(fontSize: 18), + ), + ), + ], + ), - const SizedBox(height: 30), + const SizedBox(height: 30), - SettingsEntry( - icon: Icons.download_for_offline_outlined, - text: AppLocalizations.of(context)!.imageSource, - rawText: 'Image source', - selected: context.select((SettingsProvider p) => p.getImageSource), - update: context.read().setImageSource, - ), + SettingsEntry( + icon: Icons.download_for_offline_outlined, + text: AppLocalizations.of(context)!.imageSource, + rawText: 'Image source', + selected: context + .select((SettingsProvider p) => p.getImageSource), + update: + context.read().setImageSource, + ), - SettingsEntry( - icon: Icons.colorize, - text: AppLocalizations.of(context)!.colorSource, - rawText: 'Color source', - selected: colorSource, - update: context.read().setColorSource, - ), + SettingsEntry( + icon: Icons.colorize, + text: AppLocalizations.of(context)!.colorSource, + rawText: 'Color source', + selected: colorSource, + update: context.read().setColorSource, + ), - const SizedBox(height: 30,), + const SizedBox( + height: 30, + ), - if (colorSource == "custom") SizedBox( - height: 65, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: settingSwitches["Custom color"]!.length, - itemBuilder: (BuildContext context, int index) { - String name = settingSwitches["Custom color"]![index]; - return GestureDetector( - onTap: () { - HapticFeedback.mediumImpact(); - context.read().setCustomColorScheme(name); - }, - child: Padding( - padding: const EdgeInsets.all(3.0), - child: AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: Color(getColorFromHex(name)), - borderRadius: BorderRadius.circular(33) - ), + if (colorSource == "custom") + SizedBox( + height: 65, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: + settingSwitches["Custom color"]!.length, + itemBuilder: (BuildContext context, int index) { + String name = + settingSwitches["Custom color"]![index]; + return GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + context + .read() + .setCustomColorScheme(name); + }, + child: Padding( + padding: const EdgeInsets.all(3.0), + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: Color( + getColorFromHex(name)), + borderRadius: + BorderRadius.circular(33)), + ), + if (customColorHex == name) + const Center( + child: Icon( + Icons.check, + color: Colors.white, + )) + ], ), - if (customColorHex == name) const Center( - child: Icon(Icons.check, color: Colors.white,)) - ], + ), ), - ), - ), - ); - }, - ), - ), + ); + }, + ), + ), - const SizedBox(height: 20,), + const SizedBox( + height: 20, + ), - //settingEntry(Icons.colorize_rounded, localizations.colorSource, settings, palette, updatePage, 'Color source', context), + //settingEntry(Icons.colorize_rounded, localizations.colorSource, settings, palette, updatePage, 'Color source', context), - /* + /* if (settings["Color source"] == "custom") SizedBox( height: 80, child: ListView.builder( @@ -201,23 +238,22 @@ class AppearancePage extends StatelessWidget { ), */ - /* + /* settingEntry(Icons.image_outlined, localizations.imageSource, settings, palette, updatePage, 'Image source', context), */ - const SizedBox(height: 70,), - ], - ) - ), + const SizedBox( + height: 70, + ), + ], + )), ), ), ), ], ), ); - } - } class UnitsPage extends StatelessWidget { @@ -225,63 +261,67 @@ class UnitsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( color: Theme.of(context).colorScheme.surface, child: CustomScrollView( slivers: [ SliverAppBar.large( - leading: - IconButton(icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary,), + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.primary, + ), onPressed: () { HapticFeedback.lightImpact(); Navigator.pop(context); }), - title: Text(AppLocalizations.of(context)!.units, - style: const TextStyle(fontSize: 30),), + title: Text( + AppLocalizations.of(context)!.units, + style: const TextStyle(fontSize: 30), + ), backgroundColor: Theme.of(context).colorScheme.surface, pinned: false, ), - SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 30, right: 30), child: AnimationLimiter( child: Column( - children: AnimationConfiguration.toStaggeredList( - duration: const Duration(milliseconds: 500), - childAnimationBuilder: (widget) => SlideAnimation( - horizontalOffset: 80.0, - child: FadeInAnimation( - child: widget, - ), + children: AnimationConfiguration.toStaggeredList( + duration: const Duration(milliseconds: 500), + childAnimationBuilder: (widget) => SlideAnimation( + horizontalOffset: 80.0, + child: FadeInAnimation( + child: widget, ), - children: [ - - SettingsEntry( - icon: Icons.ac_unit, - text: AppLocalizations.of(context)!.temperature, - rawText: 'Temperature', - selected: context.select((SettingsProvider p) => p.getTempUnit), - update: context.read().setTempUnit, - ), - SettingsEntry( - icon: Icons.water_drop_outlined, - text: AppLocalizations.of(context)!.precipitaion, - rawText: 'Precipitation', - selected: context.select((SettingsProvider p) => p.getPrecipUnit), - update: context.read().setPrecipUnit, - ), - SettingsEntry( - icon: Icons.air, - text: AppLocalizations.of(context)!.windCapital, - rawText: 'Wind', - selected: context.select((SettingsProvider p) => p.getWindUnit), - update: context.read().setWindUnit, - ), - ], - ) - ), + ), + children: [ + SettingsEntry( + icon: Icons.ac_unit, + text: AppLocalizations.of(context)!.temperature, + rawText: 'Temperature', + selected: + context.select((SettingsProvider p) => p.getTempUnit), + update: context.read().setTempUnit, + ), + SettingsEntry( + icon: Icons.water_drop_outlined, + text: AppLocalizations.of(context)!.precipitaion, + rawText: 'Precipitation', + selected: context + .select((SettingsProvider p) => p.getPrecipUnit), + update: context.read().setPrecipUnit, + ), + SettingsEntry( + icon: Icons.air, + text: AppLocalizations.of(context)!.windCapital, + rawText: 'Wind', + selected: + context.select((SettingsProvider p) => p.getWindUnit), + update: context.read().setWindUnit, + ), + ], + )), ), ), ), @@ -296,103 +336,109 @@ class GeneralSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( color: Theme.of(context).colorScheme.surface, child: CustomScrollView( slivers: [ - SliverAppBar.large( - leading: - IconButton(icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary,), - onPressed: () { - HapticFeedback.lightImpact(); - Navigator.pop(context); - }), - title: Text(AppLocalizations.of(context)!.general, - style: const TextStyle(fontSize: 30),), + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.pop(context); + }), + title: Text( + AppLocalizations.of(context)!.general, + style: const TextStyle(fontSize: 30), + ), backgroundColor: Theme.of(context).colorScheme.surface, pinned: false, ), - SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 30, right: 30), child: AnimationLimiter( child: Column( - children: AnimationConfiguration.toStaggeredList( - duration: const Duration(milliseconds: 500), - childAnimationBuilder: (widget) => SlideAnimation( - horizontalOffset: 80.0, - child: FadeInAnimation( - child: widget, - ), + children: AnimationConfiguration.toStaggeredList( + duration: const Duration(milliseconds: 500), + childAnimationBuilder: (widget) => SlideAnimation( + horizontalOffset: 80.0, + child: FadeInAnimation( + child: widget, ), - children: [ - - SettingsEntry( - icon: Icons.access_time_outlined, - text: AppLocalizations.of(context)!.timeMode, - rawText: 'Time mode', - selected: context.select((SettingsProvider p) => p.getTimeMode), - update: context.read().setTimeMode, - ), - - SettingsEntry( - icon: Icons.date_range, - text: AppLocalizations.of(context)!.dateFormat, - rawText: 'Date format', - selected: context.select((SettingsProvider p) => p.getDateFormat), - update: context.read().setDateFormat, - ), - - SwitchSettingEntry( - icon: Icons.vibration, - text: AppLocalizations.of(context)!.radarHaptics, - selected: context.select((SettingsProvider p) => p.getRadarHapticsOn), - update: context.read().setRadarHaptics, - ), - - SettingsEntry( - icon: Icons.manage_search, - text: AppLocalizations.of(context)!.searchProvider, - rawText: 'Search provider', - selected: context.select((SettingsProvider p) => p.getSearchProvider), - update: context.read().setSearchProvider, - ), - - Padding( - padding: const EdgeInsets.only(top: 14, bottom: 14), - child: Row( - children: [ - circleBorderIcon(Icons.format_size_rounded, context), - const SizedBox(width: 20,), - Expanded(child: Text(AppLocalizations.of(context)!.fontSize, - style: const TextStyle(fontSize: 20, height: 1.2),),), - SliderTheme( + ), + children: [ + SettingsEntry( + icon: Icons.access_time_outlined, + text: AppLocalizations.of(context)!.timeMode, + rawText: 'Time mode', + selected: + context.select((SettingsProvider p) => p.getTimeMode), + update: context.read().setTimeMode, + ), + SettingsEntry( + icon: Icons.date_range, + text: AppLocalizations.of(context)!.dateFormat, + rawText: 'Date format', + selected: context + .select((SettingsProvider p) => p.getDateFormat), + update: context.read().setDateFormat, + ), + SwitchSettingEntry( + icon: Icons.vibration, + text: AppLocalizations.of(context)!.radarHaptics, + selected: context + .select((SettingsProvider p) => p.getRadarHapticsOn), + update: context.read().setRadarHaptics, + ), + Padding( + padding: const EdgeInsets.only(top: 14, bottom: 14), + child: Row( + children: [ + circleBorderIcon(Icons.format_size_rounded, context), + const SizedBox( + width: 20, + ), + Expanded( + flex: 2, + child: Text( + AppLocalizations.of(context)!.fontSize, + style: const TextStyle(fontSize: 20, height: 1.2), + maxLines: 2, + ), + ), + Expanded( + flex: 3, + child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 19, - thumbColor: Theme.of(context).colorScheme.secondary, - activeTrackColor: Theme.of(context).colorScheme.secondary, - + thumbColor: + Theme.of(context).colorScheme.secondary, + activeTrackColor: + Theme.of(context).colorScheme.secondary, year2023: false, ), child: Slider( - min: 0.7, - max: 1.3, - divisions: 10, - value: context.select((SettingsProvider p) => p.getTextScale), + min: 0.7, + max: 1.3, + divisions: 10, + value: context.select( + (SettingsProvider p) => p.getTextScale), onChanged: (double value) { - context.read().setTextScale(value); - } - ), + context + .read() + .setTextScale(value); + }), ), - ], - ), + ), + ], ), - ], - ) - ), + ), + ], + )), ), ), ), @@ -407,8 +453,8 @@ class LanguagePage extends StatelessWidget { @override Widget build(BuildContext context) { - - String selectedLocale = context.select((SettingsProvider p) => p.getLocaleName); + String selectedLocale = + context.select((SettingsProvider p) => p.getLocaleName); List options = settingSwitches["Language"]!; return Material( @@ -416,24 +462,30 @@ class LanguagePage extends StatelessWidget { child: CustomScrollView( slivers: [ SliverAppBar.large( - leading: - IconButton(icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary,), + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: Theme.of(context).colorScheme.primary, + ), onPressed: () { HapticFeedback.lightImpact(); Navigator.pop(context); }), - title: Text(AppLocalizations.of(context)!.language, style: const TextStyle(fontSize: 30),), + title: Text( + AppLocalizations.of(context)!.language, + style: const TextStyle(fontSize: 30), + ), backgroundColor: Theme.of(context).colorScheme.surface, pinned: false, ), - SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 25, right: 25, top: 30), child: GestureDetector( onTap: () { HapticFeedback.lightImpact(); - _launchUrl("https://hosted.weblate.org/engage/overmorrow-weather/"); + _launchUrl( + "https://hosted.weblate.org/engage/overmorrow-weather/"); }, child: Container( decoration: BoxDecoration( @@ -445,9 +497,15 @@ class LanguagePage extends StatelessWidget { child: Row( children: [ Text(AppLocalizations.of(context)!.helpTranslate, - style: TextStyle(color: Theme.of(context).colorScheme.tertiary, fontSize: 21)), + style: TextStyle( + color: Theme.of(context).colorScheme.tertiary, + fontSize: 21)), const Spacer(), - Icon(Icons.arrow_forward, color: Theme.of(context).colorScheme.tertiary, size: 23,) + Icon( + Icons.arrow_forward, + color: Theme.of(context).colorScheme.tertiary, + size: 23, + ) ], ), ), @@ -458,7 +516,8 @@ class LanguagePage extends StatelessWidget { SliverToBoxAdapter( child: AnimationLimiter( child: ListView.builder( - padding: const EdgeInsets.only(top: 30, left: 30, right: 30, bottom: 40), + padding: const EdgeInsets.only( + top: 30, left: 30, right: 30, bottom: 40), physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: options.length, @@ -472,20 +531,28 @@ class LanguagePage extends StatelessWidget { child: ListTile( onTap: () { HapticFeedback.mediumImpact(); - context.read().setLocale(options[index]); + context + .read() + .setLocale(options[index]); }, title: Padding( - padding: const EdgeInsets.only(top: 12, bottom: 12, left: 13), - child: Text(options[index], style: const TextStyle(fontSize: 20),) - ), + padding: const EdgeInsets.only( + top: 12, bottom: 12, left: 13), + child: Text( + options[index], + style: const TextStyle(fontSize: 20), + )), contentPadding: EdgeInsets.zero, trailing: Radio( - fillColor: WidgetStateProperty.all(Theme.of(context).colorScheme.primary), + fillColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.primary), value: options[index], groupValue: selectedLocale, onChanged: (String? value) { HapticFeedback.mediumImpact(); - context.read().setLocale(options[index]); + context + .read() + .setLocale(options[index]); }, ), ), @@ -503,15 +570,21 @@ class LanguagePage extends StatelessWidget { } class LayoutPage extends StatelessWidget { - const LayoutPage({super.key}); //also the default order - static const allNames = ["sunstatus", "rain indicator", "hourly", "alerts", "radar", "daily", "air quality"]; + static const allNames = [ + "sunstatus", + "rain indicator", + "hourly", + "alerts", + "radar", + "daily", + "air quality" + ]; @override Widget build(BuildContext context) { - List _items = context.watch().getLayout; List removed = []; @@ -527,7 +600,8 @@ class LayoutPage extends StatelessWidget { slivers: [ SliverAppBar.large( leading: IconButton( - icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary), + icon: Icon(Icons.arrow_back, + color: Theme.of(context).colorScheme.primary), onPressed: () { HapticFeedback.lightImpact(); Navigator.pop(context); @@ -537,7 +611,11 @@ class LayoutPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(right: 10), child: IconButton( - icon: Icon(Icons.restore, color: Theme.of(context).colorScheme.primary, size: 26,), + icon: Icon( + Icons.restore, + color: Theme.of(context).colorScheme.primary, + size: 26, + ), onPressed: () { HapticFeedback.heavyImpact(); context.read().setLayoutOrder(allNames); @@ -545,7 +623,10 @@ class LayoutPage extends StatelessWidget { ), ), ], - title: Text(AppLocalizations.of(context)!.layout, style: const TextStyle(fontSize: 30),), + title: Text( + AppLocalizations.of(context)!.layout, + style: const TextStyle(fontSize: 30), + ), backgroundColor: Theme.of(context).colorScheme.surface, pinned: false, ), @@ -560,7 +641,8 @@ class LayoutPage extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: child, ), - padding: const EdgeInsets.only(left: 25, right: 25, top: 10, bottom: 50), + padding: const EdgeInsets.only( + left: 25, right: 25, top: 10, bottom: 50), children: [ for (int index = 0; index < _items.length; index += 1) Container( @@ -569,30 +651,42 @@ class LayoutPage extends StatelessWidget { padding: const EdgeInsets.all(4), child: Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, + color: + Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(33), ), height: 67, - padding: const EdgeInsets.only(top: 6, bottom: 6, left: 20, right: 10), + padding: const EdgeInsets.only( + top: 6, bottom: 6, left: 20, right: 10), child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 10), - child: Icon(Icons.drag_indicator, color: Theme.of(context).colorScheme.outline,), + child: Icon( + Icons.drag_indicator, + color: Theme.of(context).colorScheme.outline, + ), ), Expanded( - child: Text(_items[index], style: const TextStyle(fontSize: 19),), + child: Text( + _items[index], + style: const TextStyle(fontSize: 19), + ), ), IconButton( onPressed: () { HapticFeedback.heavyImpact(); - final List newOrder = List.from(_items); + final List newOrder = + List.from(_items); newOrder.removeAt(index); - context.read().setLayoutOrder(newOrder); + context + .read() + .setLayoutOrder(newOrder); }, icon: Icon( Icons.remove_circle_outline_rounded, - color: Theme.of(context).colorScheme.tertiary, size: 23, + color: Theme.of(context).colorScheme.tertiary, + size: 23, ), ) ], @@ -611,7 +705,7 @@ class LayoutPage extends StatelessWidget { }, ), Padding( - padding: const EdgeInsets.only(top:0, left: 20, right: 20), + padding: const EdgeInsets.only(top: 0, left: 20, right: 20), child: Wrap( spacing: 6, runSpacing: 6, @@ -621,21 +715,34 @@ class LayoutPage extends StatelessWidget { HapticFeedback.mediumImpact(); final List newOrder = List.from(_items); newOrder.add(removed[i]); - context.read().setLayoutOrder(newOrder); + context + .read() + .setLayoutOrder(newOrder); }, child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - border: Border.all(width: 2, color: Theme.of(context).colorScheme.outlineVariant) - ), + borderRadius: BorderRadius.circular(18), + border: Border.all( + width: 2, + color: Theme.of(context) + .colorScheme + .outlineVariant)), padding: const EdgeInsets.all(10), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.add_rounded, color: Theme.of(context).colorScheme.primary, size: 22,), + Icon( + Icons.add_rounded, + color: Theme.of(context).colorScheme.primary, + size: 22, + ), Padding( - padding: const EdgeInsets.only(left: 3, right: 3), - child: Text(removed[i], style: const TextStyle(fontSize: 17),), + padding: + const EdgeInsets.only(left: 3, right: 3), + child: Text( + removed[i], + style: const TextStyle(fontSize: 17), + ), ), ], ), @@ -651,4 +758,4 @@ class LayoutPage extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/search_screens.dart b/lib/search_screens.dart index b2ea5fdb..a9c0495a 100644 --- a/lib/search_screens.dart +++ b/lib/search_screens.dart @@ -29,18 +29,16 @@ import 'package:overmorrow/services/preferences_service.dart'; import 'package:overmorrow/services/widget_service.dart'; import 'package:overmorrow/pages/settings_pages/settings_page.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; -import 'package:provider/provider.dart'; import 'l10n/app_localizations.dart'; - //before this the same place from 2 different providers would be registered as different, //I am trying to fix this with this String generateSimplifier(var split) { return "${split["name"]}, ${split["lat"].toStringAsFixed(2)}, ${split["lon"].toStringAsFixed(2)}"; } -bool isUppercase(String str){ +bool isUppercase(String str) { return str == str.toUpperCase(); } @@ -60,20 +58,22 @@ String generateAbbreviation(String countryName) { } } - -class MySearchWidget extends StatefulWidget{ +class MySearchWidget extends StatefulWidget { final place; final updateLocation; final isTabletMode; - const MySearchWidget({super.key, required this.place, required this.updateLocation, required this.isTabletMode}); + const MySearchWidget( + {super.key, + required this.place, + required this.updateLocation, + required this.isTabletMode}); @override _MySearchWidgetState createState() => _MySearchWidgetState(); } class _MySearchWidgetState extends State { - final ValueNotifier> recommend = ValueNotifier>([]); late ValueNotifier> favorites; @@ -84,7 +84,9 @@ class _MySearchWidgetState extends State { } List getFavorites() { - final ifnot = ["{\n \"id\": 2651922,\n \"name\": \"Nashville\",\n \"region\": \"Tennessee\",\n \"country\": \"United States of America\",\n \"lat\": 36.17,\n \"lon\": -86.78,\n \"url\": \"nashville-tennessee-united-states-of-america\"\n }"]; + final ifnot = [ + "{\n \"id\": 2651922,\n \"name\": \"Nashville\",\n \"region\": \"Tennessee\",\n \"country\": \"United States of America\",\n \"lat\": 36.17,\n \"lon\": -86.78,\n \"url\": \"nashville-tennessee-united-states-of-america\"\n }" + ]; final used = PreferenceUtils.getStringList('favorites', ifnot); return used; } @@ -108,17 +110,25 @@ class _MySearchWidgetState extends State { } @override - Widget build(BuildContext context){ - + Widget build(BuildContext context) { if (widget.isTabletMode) { - return HeroSearchPage(place: widget.place, recommend: recommend, - updateRec: updateRec, updateLocation: widget.updateLocation, favorites: favorites, updateFav: updateFav, + return HeroSearchPage( + place: widget.place, + recommend: recommend, + updateRec: updateRec, + updateLocation: widget.updateLocation, + favorites: favorites, + updateFav: updateFav, isTabletMode: true); } - return SearchBar(recommend: recommend, updateLocation: widget.updateLocation, - updateFav: updateFav, favorites: favorites, updateRec: updateRec, place: widget.place); - + return SearchBar( + recommend: recommend, + updateLocation: widget.updateLocation, + updateFav: updateFav, + favorites: favorites, + updateRec: updateRec, + place: widget.place); } } @@ -130,9 +140,14 @@ class SearchBar extends StatelessWidget { final Function updateRec; final String place; - SearchBar({super.key, required this.recommend, required this.updateLocation, - required this.updateFav, required this.favorites, required this.updateRec, - required this.place}); + SearchBar( + {super.key, + required this.recommend, + required this.updateLocation, + required this.updateFav, + required this.favorites, + required this.updateRec, + required this.place}); @override Widget build(BuildContext context) { @@ -143,23 +158,39 @@ class SearchBar extends StatelessWidget { tag: 'searchBarHero', child: Container( height: 67, - margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15, left: 28, right: 28), + margin: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 15, + left: 28, + right: 28), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(33) - ), - padding: const EdgeInsets.only(left: 10, right: 10, top: 5, bottom: 5), + borderRadius: BorderRadius.circular(33)), + padding: + const EdgeInsets.only(left: 10, right: 10, top: 5, bottom: 5), child: Material( borderRadius: BorderRadius.circular(30), child: Row( children: [ Padding( padding: const EdgeInsets.only(left: 10, right: 13), - child: Icon(Icons.place_outlined, color: Theme.of(context).colorScheme.primary, size: 25,), + child: Icon( + Icons.place_outlined, + color: Theme.of(context).colorScheme.primary, + size: 25, + ), ), - Expanded(child: Text(place, style: const TextStyle(fontSize: 23), maxLines: 1,)), + Expanded( + child: Text( + place, + style: const TextStyle(fontSize: 23), + maxLines: 1, + )), IconButton( - icon: Icon(Icons.settings_outlined, color: Theme.of(context).colorScheme.primary, size: 25,), + icon: Icon( + Icons.settings_outlined, + color: Theme.of(context).colorScheme.primary, + size: 25, + ), onPressed: () { HapticFeedback.mediumImpact(); @@ -176,26 +207,31 @@ class SearchBar extends StatelessWidget { ), ), ), - onTap: () { HapticFeedback.lightImpact(); // i had to use my own transition because the default sliding doesn't look good here - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => HeroSearchPage(place: place, recommend: recommend, - updateRec: updateRec, updateLocation: updateLocation, favorites: favorites, updateFav: updateFav, - isTabletMode: false), - + Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + HeroSearchPage( + place: place, + recommend: recommend, + updateRec: updateRec, + updateLocation: updateLocation, + favorites: favorites, + updateFav: updateFav, + isTabletMode: false), transitionDuration: const Duration(milliseconds: 250), reverseTransitionDuration: const Duration(milliseconds: 250), - transitionsBuilder: (context, animation, secondaryAnimation, child) { + transitionsBuilder: + (context, animation, secondaryAnimation, child) { const begin = Offset(0.0, 0.1); const end = Offset.zero; const curve = Curves.easeOutCubic; final tween = Tween(begin: begin, end: end); - final curvedAnimation = CurvedAnimation(parent: animation, curve: curve); + final curvedAnimation = + CurvedAnimation(parent: animation, curve: curve); return FadeTransition( opacity: animation, @@ -204,18 +240,14 @@ class SearchBar extends StatelessWidget { child: child, ), ); - } - ) - ); + })); }, ), ); } } - class HeroSearchPage extends StatefulWidget { - final String place; final recommend; final updateRec; @@ -224,19 +256,28 @@ class HeroSearchPage extends StatefulWidget { final updateFav; final isTabletMode; - const HeroSearchPage({super.key, required this.place, - required this.recommend, required this.updateRec, required this.updateLocation, required this.favorites, - required this.updateFav, required this.isTabletMode}); + const HeroSearchPage( + {super.key, + required this.place, + required this.recommend, + required this.updateRec, + required this.updateLocation, + required this.favorites, + required this.updateFav, + required this.isTabletMode}); @override - State createState() => _HeroSearchPageState(place: place, - recommend: recommend, updateRec: updateRec, updateLocation: updateLocation, favorites: favorites, - updateFav: updateFav, isTabletMode: isTabletMode); + State createState() => _HeroSearchPageState( + place: place, + recommend: recommend, + updateRec: updateRec, + updateLocation: updateLocation, + favorites: favorites, + updateFav: updateFav, + isTabletMode: isTabletMode); } - class _HeroSearchPageState extends State { - final String place; final recommend; final updateRec; @@ -245,9 +286,14 @@ class _HeroSearchPageState extends State { final updateFav; final isTabletMode; - _HeroSearchPageState({required this.place, - required this.recommend, required this.updateRec, required this.updateLocation, required this.favorites, - required this.updateFav, required this.isTabletMode}); + _HeroSearchPageState( + {required this.place, + required this.recommend, + required this.updateRec, + required this.updateLocation, + required this.favorites, + required this.updateFav, + required this.isTabletMode}); String text = ""; bool isEditing = false; @@ -267,7 +313,7 @@ class _HeroSearchPageState extends State { if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () async { if (mounted) { - var result = await LocationService.getRecommendation(query, context.read().getSearchProvider); + var result = await LocationService.getRecommendation(query); updateRec(result); } }); @@ -287,12 +333,11 @@ class _HeroSearchPageState extends State { if (!isTabletMode) { Navigator.pop(context); } - var rec = await LocationService.getRecommendation(submitted, context.read().getSearchProvider); + var rec = await LocationService.getRecommendation(submitted); if (rec.isNotEmpty) { var split = json.decode(rec[0]); updateLocation('${split["lat"]}, ${split["lon"]}', split["name"]); - } - else { + } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -301,7 +346,8 @@ class _HeroSearchPageState extends State { child: Text("Unable to find place: $submitted"), ), behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.0)), ), ); } @@ -322,12 +368,13 @@ class _HeroSearchPageState extends State { saveCurrentLocationToWidget(Position position) async { await PreferenceUtils.setString('LastKnownPositionName', placeName); - await PreferenceUtils.setString('LastKnownPositionCord', "${position.latitude.toStringAsFixed(2)}, ${position.longitude.toStringAsFixed(2)}"); - WidgetService.saveData("widget.lastKnownPlace", placeName); //save the name of the place to the widgets + await PreferenceUtils.setString('LastKnownPositionCord', + "${position.latitude.toStringAsFixed(2)}, ${position.longitude.toStringAsFixed(2)}"); + WidgetService.saveData("widget.lastKnownPlace", + placeName); //save the name of the place to the widgets } findCurrentPosition() async { - Position position; setState(() { @@ -338,15 +385,22 @@ class _HeroSearchPageState extends State { try { position = (await Geolocator.getLastKnownPosition())!; - List placemarks = await placemarkFromCoordinates( - position.latitude, position.longitude); + List placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); Placemark place = placemarks[0]; setState(() { - placeName = place.locality ?? place.subLocality ?? place.thoroughfare ?? place.subThoroughfare ?? place.name ?? + placeName = place.locality ?? + place.subLocality ?? + place.thoroughfare ?? + place.subThoroughfare ?? + place.name ?? "${position.latitude.toStringAsFixed(2)}, ${position.longitude.toStringAsFixed(2)}"; country = place.isoCountryCode ?? place.country ?? ""; - region = place.administrativeArea ?? place.subAdministrativeArea ?? place.subLocality ?? ""; + region = place.administrativeArea ?? + place.subAdministrativeArea ?? + place.subLocality ?? + ""; placeLatLon = "${position.latitude}, ${position.longitude}"; locationState = "enabled"; @@ -360,10 +414,9 @@ class _HeroSearchPageState extends State { try { position = await Geolocator.getCurrentPosition( - locationSettings: AndroidSettings(accuracy: LocationAccuracy.medium, - timeLimit: const Duration(seconds: 10) - ) - ); + locationSettings: AndroidSettings( + accuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 10))); } on Error { setState(() { locationState = "disabled"; @@ -373,7 +426,8 @@ class _HeroSearchPageState extends State { } on LocationServiceDisabledException { setState(() { locationState = "disabled"; - locationMessage = AppLocalizations.of(context)!.locationServicesAreDisabled; + locationMessage = + AppLocalizations.of(context)!.locationServicesAreDisabled; }); return "disabled"; } on TimeoutException { @@ -388,25 +442,27 @@ class _HeroSearchPageState extends State { } try { - - List placemarks = await placemarkFromCoordinates( - position.latitude, position.longitude); + List placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); Placemark place = placemarks[0]; setState(() { - placeName = place.locality ?? place.subLocality ?? place.thoroughfare ?? place.subThoroughfare ?? ""; + placeName = place.locality ?? + place.subLocality ?? + place.thoroughfare ?? + place.subThoroughfare ?? + ""; country = place.isoCountryCode ?? place.country ?? ""; region = place.administrativeArea ?? place.subAdministrativeArea ?? ""; locationState = "enabled"; }); await saveCurrentLocationToWidget(position); - } on Error { - if (!mounted) return; setState(() { - placeName = "${position.latitude.toStringAsFixed(2)}, ${position.longitude.toStringAsFixed(2)}"; + placeName = + "${position.latitude.toStringAsFixed(2)}, ${position.longitude.toStringAsFixed(2)}"; }); } } @@ -416,7 +472,8 @@ class _HeroSearchPageState extends State { if (!serviceEnabled) { setState(() { locationState = "disabled"; - locationMessage = AppLocalizations.of(context)!.locationServicesAreDisabled; + locationMessage = + AppLocalizations.of(context)!.locationServicesAreDisabled; }); return "disabled"; } @@ -424,7 +481,8 @@ class _HeroSearchPageState extends State { if (permission == LocationPermission.deniedForever) { setState(() { locationState = "deniedForever"; - locationMessage = AppLocalizations.of(context)!.locationPermissionDeniedForever; + locationMessage = + AppLocalizations.of(context)!.locationPermissionDeniedForever; }); return "disabled"; } @@ -439,7 +497,8 @@ class _HeroSearchPageState extends State { if (!serviceEnabled) { setState(() { locationState = "disabled"; - locationMessage = AppLocalizations.of(context)!.locationServicesAreDisabled; + locationMessage = + AppLocalizations.of(context)!.locationServicesAreDisabled; }); return "disabled"; } @@ -448,14 +507,16 @@ class _HeroSearchPageState extends State { if (permission == LocationPermission.deniedForever) { setState(() { locationState = "deniedForever"; - locationMessage = AppLocalizations.of(context)!.locationPermissionDeniedForever; + locationMessage = + AppLocalizations.of(context)!.locationPermissionDeniedForever; }); return "disabled"; } if (permission == LocationPermission.denied) { setState(() { locationState = "denied"; - locationMessage = AppLocalizations.of(context)!.locationPermissionIsDenied; + locationMessage = + AppLocalizations.of(context)!.locationPermissionIsDenied; }); return "disabled"; } @@ -479,10 +540,10 @@ class _HeroSearchPageState extends State { _controller = TextEditingController(); - WidgetsBinding.instance.addPostFrameCallback((_){ + WidgetsBinding.instance.addPostFrameCallback((_) { checkIflocationState().then((x) { if (x == "enabled") { - WidgetsBinding.instance.addPostFrameCallback((_){ + WidgetsBinding.instance.addPostFrameCallback((_) { findCurrentPosition(); }); } @@ -498,62 +559,73 @@ class _HeroSearchPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: isTabletMode ? Theme.of(context).colorScheme.surfaceContainer + backgroundColor: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainer : Theme.of(context).colorScheme.surface, appBar: AppBar( - backgroundColor: isTabletMode ? Theme.of(context).colorScheme.surfaceContainer : Theme.of(context).colorScheme.surface, + backgroundColor: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.surface, foregroundColor: Theme.of(context).colorScheme.primary, surfaceTintColor: Theme.of(context).colorScheme.outlineVariant, elevation: 0, automaticallyImplyLeading: true, - leading: isTabletMode ? Padding( - padding: const EdgeInsets.only(left: 8), - child: IconButton( - icon: Icon(Icons.settings_outlined, color: Theme.of(context).colorScheme.primary, size: 23,), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage(), - ), - ); - }, - ), - ) : IconButton( - icon: Icon( - Icons.close, - color: Theme.of(context).colorScheme.primary, size: 25, - ), - onPressed: () { - HapticFeedback.lightImpact(); - Navigator.pop(context); - }, - ), - actions: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition(opacity: animation, child: child); - }, - child: (text == "") ? AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Padding( - padding: const EdgeInsets.only(right: 13), + leading: isTabletMode + ? Padding( + padding: const EdgeInsets.only(left: 8), child: IconButton( icon: Icon( - isEditing ? Icons.check : Icons.edit_outlined, - color: Theme.of(context).colorScheme.primary, size: 25, + Icons.settings_outlined, + color: Theme.of(context).colorScheme.primary, + size: 23, ), onPressed: () { - HapticFeedback.lightImpact(); - onIsEditingChanged(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsPage(), + ), + ); }, ), + ) + : IconButton( + icon: Icon( + Icons.close, + color: Theme.of(context).colorScheme.primary, + size: 25, + ), + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.pop(context); + }, ), - ) - : Container(), + actions: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: (text == "") + ? AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Padding( + padding: const EdgeInsets.only(right: 13), + child: IconButton( + icon: Icon( + isEditing ? Icons.check : Icons.edit_outlined, + color: Theme.of(context).colorScheme.primary, + size: 25, + ), + onPressed: () { + HapticFeedback.lightImpact(); + onIsEditingChanged(); + }, + ), + ), + ) + : Container(), ) ], bottom: PreferredSize( @@ -564,19 +636,22 @@ class _HeroSearchPageState extends State { height: 67, margin: const EdgeInsets.only(left: 27, right: 27, bottom: 20), decoration: BoxDecoration( - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(33) - ), + color: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(33)), child: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 30, right: 30), child: Material( - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, + color: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.surfaceContainer, child: TextField( autofocus: false, controller: _controller, - onChanged: (String to) async{ + onChanged: (String to) async { setState(() { text = to; }); @@ -613,9 +688,22 @@ class _HeroSearchPageState extends State { key: ValueKey(text == ""), alignment: Alignment.topCenter, child: SingleChildScrollView( - child: buildRecommend(text, favorites, recommend, - updateLocation, onFavChanged, isEditing, locationState, locationMessage, askGrantLocationPermission, - placeName, country, region, placeLatLon, isTabletMode, _onSearchCleared), + child: buildRecommend( + text, + favorites, + recommend, + updateLocation, + onFavChanged, + isEditing, + locationState, + locationMessage, + askGrantLocationPermission, + placeName, + country, + region, + placeLatLon, + isTabletMode, + _onSearchCleared), ), ), ), @@ -626,94 +714,148 @@ class _HeroSearchPageState extends State { } } -Widget buildRecommend(String text, ValueListenable> favoritesListen, - ValueListenable> recommend, updateLocation, onFavChanged, isEditing, locationState, locationMessage, - askGrantLocationPermission, placeName, country, region, placeLatLon, isTabletMode, onSearchCleared) { - +Widget buildRecommend( + String text, + ValueListenable> favoritesListen, + ValueListenable> recommend, + updateLocation, + onFavChanged, + isEditing, + locationState, + locationMessage, + askGrantLocationPermission, + placeName, + country, + region, + placeLatLon, + isTabletMode, + onSearchCleared) { return ValueListenableBuilder( - valueListenable: favoritesListen, - builder: (context, value, child) { - List favorites = value; - if (text == "") { - return Padding( - padding: const EdgeInsets.only(left: 30, top: 10, right: 30, bottom: 40), - child: AnimationLimiter( - child: Column( - children: AnimationConfiguration.toStaggeredList( - duration: const Duration(milliseconds: 475), - childAnimationBuilder: (widget) => - SlideAnimation( + valueListenable: favoritesListen, + builder: (context, value, child) { + List favorites = value; + if (text == "") { + return Padding( + padding: + const EdgeInsets.only(left: 30, top: 10, right: 30, bottom: 40), + child: AnimationLimiter( + child: Column( + children: AnimationConfiguration.toStaggeredList( + duration: const Duration(milliseconds: 475), + childAnimationBuilder: (widget) => SlideAnimation( horizontalOffset: 0.0, verticalOffset: 50, child: FadeInAnimation( child: widget, ), ), - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 10, top: 2), - child: Icon( - Icons.gps_fixed, color: Theme.of(context).colorScheme.outline, size: 17,), - ), - Text(AppLocalizations.of(context)!.currentLocation, - style: TextStyle(color: Theme.of(context).colorScheme.outline, fontSize: 18, height: 1.2),) - ], - ), - const SizedBox(height: 14,), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(46), - border: Border.all(width: 0, color: Theme.of(context).colorScheme.primaryFixedDim) - ), - padding: const EdgeInsets.all(0), - child: currentLocationWidget(locationState, locationMessage, askGrantLocationPermission, - placeName, country, region, updateLocation, context, placeLatLon, isTabletMode), - ), - const SizedBox(height: 30,), - Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Row( + children: [ + Row( children: [ Padding( - padding: const EdgeInsets.only(right: 10, top: 0), + padding: const EdgeInsets.only(right: 10, top: 2), child: Icon( - Icons.star_outline, color: Theme.of(context).colorScheme.outline, size: 18,), + Icons.gps_fixed, + color: Theme.of(context).colorScheme.outline, + size: 17, + ), ), - Text(AppLocalizations.of(context)!.favoritesLowercase, - style: TextStyle(color: Theme.of(context).colorScheme.outline, fontSize: 18, height: 1.2),) + Text( + AppLocalizations.of(context)!.currentLocation, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 18, + height: 1.2), + ) ], ), - ), - if (favorites.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(30), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, - Animation animation) { - return SizeTransition( - sizeFactor: animation, child: child); - }, - child: favoritesOrReorder(isEditing, favorites, onFavChanged, updateLocation, context, isTabletMode), + const SizedBox( + height: 14, ), - ) - ], + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(46), + border: Border.all( + width: 0, + color: Theme.of(context) + .colorScheme + .primaryFixedDim)), + padding: const EdgeInsets.all(0), + child: currentLocationWidget( + locationState, + locationMessage, + askGrantLocationPermission, + placeName, + country, + region, + updateLocation, + context, + placeLatLon, + isTabletMode), + ), + const SizedBox( + height: 30, + ), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10, top: 0), + child: Icon( + Icons.star_outline, + color: Theme.of(context).colorScheme.outline, + size: 18, + ), + ), + Text( + AppLocalizations.of(context)!.favoritesLowercase, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 18, + height: 1.2), + ) + ], + ), + ), + if (favorites.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(30), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: + (Widget child, Animation animation) { + return SizeTransition( + sizeFactor: animation, child: child); + }, + child: favoritesOrReorder( + isEditing, + favorites, + onFavChanged, + updateLocation, + context, + isTabletMode), + ), + ) + ], + ), ), ), - ), - ); - } - else { - return buildSearchResults(favorites, recommend, updateLocation, onFavChanged, isTabletMode, onSearchCleared); - } - } - ); + ); + } else { + return buildSearchResults(favorites, recommend, updateLocation, + onFavChanged, isTabletMode, onSearchCleared); + } + }); } -Widget buildSearchResults(List favorites, ValueListenable> recommend, updateLocation, - onFavChanged, isTabletMode, onSearchCleared) { +Widget buildSearchResults( + List favorites, + ValueListenable> recommend, + updateLocation, + onFavChanged, + isTabletMode, + onSearchCleared) { List favoriteNarrow = []; for (int i = 0; i < favorites.length; i++) { var d = jsonDecode(favorites[i]); @@ -724,21 +866,22 @@ Widget buildSearchResults(List favorites, ValueListenable> builder: (context, value, child) { List rec = value; return Padding( - padding: const EdgeInsets.only( - top: 0, bottom: 30, left: 30, right: 30), + padding: + const EdgeInsets.only(top: 0, bottom: 30, left: 30, right: 30), child: ClipRRect( borderRadius: BorderRadius.circular(30), child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, - Animation animation) { - return SizeTransition( - sizeFactor: animation, child: child); + transitionBuilder: (Widget child, Animation animation) { + return SizeTransition(sizeFactor: animation, child: child); }, child: Container( key: ValueKey(rec.toString()), decoration: BoxDecoration( - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest + color: isTabletMode + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(30), ), @@ -747,87 +890,97 @@ Widget buildSearchResults(List favorites, ValueListenable> : const EdgeInsets.all(14), child: Column( children: List.generate(rec.length, (index) { - var split = json.decode(rec[index]); - String name = split["name"]; - String country = generateAbbreviation(split["country"]); - String region = split["region"]; - String simplifier = generateSimplifier(split); - - bool contained = favoriteNarrow.contains(simplifier); - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - HapticFeedback.lightImpact(); - updateLocation( - '${split["lat"]}, ${split["lon"]}', - split["name"]); - if (isTabletMode) { - onSearchCleared(); - } - if (!isTabletMode) { - Navigator.pop(context); - } - }, - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 7, top: 5, bottom: 5), - child: - Row( + var split = json.decode(rec[index]); + String name = split["name"]; + String country = generateAbbreviation(split["country"]); + String region = split["region"]; + String simplifier = generateSimplifier(split); + + bool contained = favoriteNarrow.contains(simplifier); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + HapticFeedback.lightImpact(); + updateLocation('${split["lat"]}, ${split["lon"]}', + split["name"]); + if (isTabletMode) { + onSearchCleared(); + } + if (!isTabletMode) { + Navigator.pop(context); + } + }, + child: Padding( + padding: const EdgeInsets.only( + left: 10, right: 7, top: 5, bottom: 5), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment : CrossAxisAlignment.start, - children: [ - Text(name, style: const TextStyle( - fontSize: 19.5, height: 1.2),), - Text("$region, $country", style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 14, height: 1.25)) - ], - ) + Text( + name, + style: const TextStyle( + fontSize: 19.5, height: 1.2), ), - IconButton( - onPressed: () { - if (contained) { - HapticFeedback.mediumImpact(); - int z = favoriteNarrow.indexOf(simplifier); - favorites.removeAt(z); - onFavChanged(favorites); - } - else{ - HapticFeedback.lightImpact(); - favorites.add(rec[index]); - onFavChanged(favorites); - } - }, - icon: Icon( - contained? Icons.star : Icons.star_outline, - color: Theme.of(context).colorScheme.primary, size: 24, - ), - ) + Text("$region, $country", + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline, + fontSize: 14, + height: 1.25)) ], - ), - ), - ); - } - ) - ) - ) - ), + )), + IconButton( + onPressed: () { + if (contained) { + HapticFeedback.mediumImpact(); + int z = favoriteNarrow.indexOf(simplifier); + favorites.removeAt(z); + onFavChanged(favorites); + } else { + HapticFeedback.lightImpact(); + favorites.add(rec[index]); + onFavChanged(favorites); + } + }, + icon: Icon( + contained ? Icons.star : Icons.star_outline, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + ) + ], + ), + ), + ); + })))), ), ); - } - ); + }); } -Widget currentLocationWidget(locationState, locationMessage, askGrantLocationPermission, - String placeName, String country, String region, updateLocation, context, placeLatLon, isTabletMode) { +Widget currentLocationWidget( + locationState, + locationMessage, + askGrantLocationPermission, + String placeName, + String country, + String region, + updateLocation, + context, + placeLatLon, + isTabletMode) { if (locationState == "denied") { return GestureDetector( onTap: () { askGrantLocationPermission(); }, child: Container( - padding: const EdgeInsets.only( - left: 25, right: 25, top: 23, bottom: 23), + padding: + const EdgeInsets.only(left: 25, right: 25, top: 23, bottom: 23), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryFixedDim, borderRadius: BorderRadius.circular(40), @@ -835,13 +988,16 @@ Widget currentLocationWidget(locationState, locationMessage, askGrantLocationPer child: Row( children: [ Icon(Icons.gps_fixed, - color: Theme.of(context).colorScheme.onPrimaryFixed, size: 19), + color: Theme.of(context).colorScheme.onPrimaryFixed, size: 19), Expanded( child: Padding( - padding: const EdgeInsets.only(left: 10, bottom: 2), - child: Text(AppLocalizations.of(context)!.grantLocationPermission, - style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: 19),) - ), + padding: const EdgeInsets.only(left: 10, bottom: 2), + child: Text( + AppLocalizations.of(context)!.grantLocationPermission, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: 19), + )), ), ], ), @@ -860,8 +1016,8 @@ Widget currentLocationWidget(locationState, locationMessage, askGrantLocationPer } }, child: Container( - padding: const EdgeInsets.only( - left: 24, right: 24, top: 19, bottom: 19), + padding: + const EdgeInsets.only(left: 24, right: 24, top: 19, bottom: 19), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryFixedDim, borderRadius: BorderRadius.circular(40), @@ -870,115 +1026,157 @@ Widget currentLocationWidget(locationState, locationMessage, askGrantLocationPer children: [ Expanded( child: Column( - crossAxisAlignment : CrossAxisAlignment.start, - children: [ - Text(placeName, style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: 19.5, height: 1.2),), - Text("$region, $country", style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: 14, height: 1.25),) - ], + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + placeName, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: 19.5, + height: 1.2), + ), + Text( + "$region, $country", + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: 14, + height: 1.25), ) - ), + ], + )), locationState == "enabled" - ? Icon(Icons.keyboard_arrow_right_rounded, - color: Theme.of(context).colorScheme.onPrimaryFixed,) - : CircularProgressIndicator(year2023: false, strokeWidth: 2.7, color: Theme.of(context).colorScheme.onPrimaryFixed, - constraints: const BoxConstraints(maxWidth: 18, maxHeight: 18, minWidth: 18, minHeight: 18)) + ? Icon( + Icons.keyboard_arrow_right_rounded, + color: Theme.of(context).colorScheme.onPrimaryFixed, + ) + : CircularProgressIndicator( + year2023: false, + strokeWidth: 2.7, + color: Theme.of(context).colorScheme.onPrimaryFixed, + constraints: const BoxConstraints( + maxWidth: 18, + maxHeight: 18, + minWidth: 18, + minHeight: 18)) ], ), ), ); } return Container( - padding: const EdgeInsets.only( - left: 25, right: 25, top: 20, bottom: 20), + padding: const EdgeInsets.only(left: 25, right: 25, top: 20, bottom: 20), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryFixedDim, borderRadius: BorderRadius.circular(40), ), child: Row( children: [ - Icon(Icons.gps_off, - color: Theme.of(context).colorScheme.onPrimaryFixed, size: 19,), + Icon( + Icons.gps_off, + color: Theme.of(context).colorScheme.onPrimaryFixed, + size: 19, + ), Expanded( child: Padding( - padding: const EdgeInsets.only(left: 10, bottom: 2), - child: Text(locationMessage, style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, fontSize: 19),) - ), + padding: const EdgeInsets.only(left: 10, bottom: 2), + child: Text( + locationMessage, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: 19), + )), ), - ], ), ); } -Widget favoritesOrReorder(isEditing, favorites, onFavChanged, updateLocation, context, isTabletMode) { +Widget favoritesOrReorder( + isEditing, favorites, onFavChanged, updateLocation, context, isTabletMode) { if (isEditing) { return reorderFavorites(favorites, onFavChanged, isTabletMode, context); - } - else { + } else { return buildFavorites(favorites, updateLocation, context, isTabletMode); } } -Widget buildFavorites(List favorites, updateLocation, context, isTabletMode) { +Widget buildFavorites( + List favorites, updateLocation, context, isTabletMode) { return SingleChildScrollView( child: Container( key: const ValueKey("normal"), padding: const EdgeInsets.only(left: 7, right: 7, top: 20, bottom: 20), decoration: BoxDecoration( - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest + color: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(30), ), child: Column( children: List.generate(favorites.length, (index) { - var split = json.decode(favorites[index]); - String name = split["name"]; - String country = generateAbbreviation(split["country"]); - String region = split["region"]; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - HapticFeedback.lightImpact(); - updateLocation( - '${split["lat"]}, ${split["lon"]}', split["name"]); - if (!isTabletMode) { - Navigator.pop(context); - } - }, - child: Container( - decoration: index == -1 ? BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(width: 3, color: Theme.of(context).colorScheme.primaryFixedDim) - //color: Theme.of(context).colorScheme.secondaryContainer - ) : const BoxDecoration(), - padding: EdgeInsets.only( - left: index == -1 ? 20 : 23, - right: index == -1 ? 17 : 20, - top: index == -1 ? 14 : index == -1 || index == -1 ? 7 : 9, - bottom: index == -1 ? 14 : index == -1 || index == -1 ? 7 : 9, - ), - child: Row( + var split = json.decode(favorites[index]); + String name = split["name"]; + String country = generateAbbreviation(split["country"]); + String region = split["region"]; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + HapticFeedback.lightImpact(); + updateLocation('${split["lat"]}, ${split["lon"]}', split["name"]); + if (!isTabletMode) { + Navigator.pop(context); + } + }, + child: Container( + decoration: index == -1 + ? BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + width: 3, + color: Theme.of(context).colorScheme.primaryFixedDim) + //color: Theme.of(context).colorScheme.secondaryContainer + ) + : const BoxDecoration(), + padding: EdgeInsets.only( + left: index == -1 ? 20 : 23, + right: index == -1 ? 17 : 20, + top: index == -1 + ? 14 + : index == -1 || index == -1 + ? 7 + : 9, + bottom: index == -1 + ? 14 + : index == -1 || index == -1 + ? 7 + : 9, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment : CrossAxisAlignment.start, - children: [ - Text(name, style: const TextStyle( - fontSize: 19.5, height: 1.2),), - Text("$region, $country", style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 14, height: 1.25)) - ], - ) + Text( + name, + style: const TextStyle(fontSize: 19.5, height: 1.2), ), - Icon(Icons.keyboard_arrow_right_rounded, color: Theme.of(context).colorScheme.primary,) + Text("$region, $country", + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 14, + height: 1.25)) ], - ), - ), - ); - }) - ) - ), + )), + Icon( + Icons.keyboard_arrow_right_rounded, + color: Theme.of(context).colorScheme.primary, + ) + ], + ), + ), + ); + }))), ); } @@ -986,7 +1184,9 @@ Widget reorderFavorites(_items, onFavChanged, bool isTabletMode, context) { return Container( key: const ValueKey("editing"), decoration: BoxDecoration( - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, + color: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(30), ), child: ReorderableListView( @@ -1013,35 +1213,43 @@ Widget reorderFavorites(_items, onFavChanged, bool isTabletMode, context) { ); } -Widget reorderableItem(List items, index, onFavChanged, isTabletMode, context) { +Widget reorderableItem( + List items, index, onFavChanged, isTabletMode, context) { var split = json.decode(items[index]); String name = split["name"]; String country = generateAbbreviation(split["country"]); String region = split["region"]; return Container( key: Key("$name, $country, $region"), - color: isTabletMode ? Theme.of(context).colorScheme.surfaceContainerHighest + color: isTabletMode + ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surfaceContainer, child: Padding( padding: const EdgeInsets.only(left: 5, right: 5, top: 7, bottom: 7), - child: - Row( + child: Row( children: [ Padding( padding: const EdgeInsets.only(right: 10), - child: Icon(Icons.drag_indicator, color: Theme.of(context).colorScheme.outline,), + child: Icon( + Icons.drag_indicator, + color: Theme.of(context).colorScheme.outline, + ), ), Expanded( child: Column( - crossAxisAlignment : CrossAxisAlignment.start, - children: [ - Text(name, style: const TextStyle( - fontSize: 19.5, height: 1.2),), - Text("$region, $country", style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 14, height: 1.25)) - ], - ) - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle(fontSize: 19.5, height: 1.2), + ), + Text("$region, $country", + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 14, + height: 1.25)) + ], + )), IconButton( onPressed: () { items.removeAt(index); @@ -1049,11 +1257,12 @@ Widget reorderableItem(List items, index, onFavChanged, isTabletMode, c }, icon: Icon( Icons.delete_outline, - color: Theme.of(context).colorScheme.primary, size: 23, + color: Theme.of(context).colorScheme.primary, + size: 23, ), ) ], ), ), ); -} \ No newline at end of file +} diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index c43cb266..c1a20542 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -22,20 +22,81 @@ import 'dart:convert'; import '../api_key.dart'; import 'caching_service.dart'; -class LocationService { - - static Future> getRecommendation(String query, String searchProvider) async { +const String _mfApiToken = '__Wj7dVSTjV9YGu1guveLyDq0g7S7TfTjaHBTPTpO0kj8__'; +class LocationService { + static Future> getRecommendation(String query) async { query = _sanitizeQuery(query); if (query == '') { return []; } - if (searchProvider == "weatherapi") { - return _getWapiRecommendation(query); - } else { - return _getOMRecommendation(query); + final results = await Future.wait([ + _getProviderRecommendations(() => _getWapiRecommendation(query)), + _getProviderRecommendations(() => _getOMRecommendation(query)), + _getProviderRecommendations(() => _getMfRecommendation(query)), + ]); + + return _deduplicateRecommendations(results.expand((items) => items)); + } + + static Future> _getProviderRecommendations( + Future> Function() fetchRecommendations) async { + try { + return await fetchRecommendations().timeout(const Duration(seconds: 5)); + } catch (e) { + return []; + } + } + + static List _deduplicateRecommendations( + Iterable recommendations) { + final seen = {}; + final deduplicated = []; + + for (final recommendation in recommendations) { + final dynamic decoded; + try { + decoded = jsonDecode(recommendation); + } catch (e) { + continue; + } + + if (decoded is! Map) { + continue; + } + + final key = _recommendationKey(decoded); + if (key == null || !seen.add(key)) { + continue; + } + + deduplicated.add(jsonEncode(decoded)); + } + + return deduplicated; + } + + static String? _recommendationKey(Map recommendation) { + final name = recommendation["name"]?.toString().trim().toLowerCase(); + final lat = _asDouble(recommendation["lat"]); + final lon = _asDouble(recommendation["lon"]); + + if (name == null || name.isEmpty || lat == null || lon == null) { + return null; + } + + return "${name}_${lat.toStringAsFixed(2)}_${lon.toStringAsFixed(2)}"; + } + + static double? _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); } + if (value is String) { + return double.tryParse(value); + } + return null; } static Future> _getWapiRecommendation(String query) async { @@ -47,8 +108,8 @@ class LocationService { var jsonbody = []; try { - var file = await cacheManager.getSingleFile(url.toString(), - headers: {'cache-control': 'private, max-age=120'}); + var file = await cacheManager.getSingleFile(url.toString(), + headers: {'cache-control': 'private, max-age=120'}); var response = await file.readAsString(); jsonbody = jsonDecode(response); } on SocketException { @@ -63,6 +124,38 @@ class LocationService { return recommendations; } + static Future> _getMfRecommendation(String query) async { + var params = { + 'q': query, + 'token': _mfApiToken, + }; + + var url = Uri.https('webservice.meteofrance.com', 'places', params); + + var jsonbody = []; + try { + var file = await cacheManager.getSingleFile(url.toString(), + key: "$query, meteo-france search", + headers: { + 'cache-control': 'private, max-age=120' + }).timeout(const Duration(seconds: 4)); + var response = await file.readAsString(); + jsonbody = jsonDecode(response); + } catch (e) { + return []; + } + + List recommendations = []; + for (var item in jsonbody) { + item["region"] = item["admin"] ?? item["admin2"] ?? ""; + item["country"] = item["country"] ?? ""; + item["lon"] = item["lon"] ?? item["longitude"]; + item["lat"] = item["lat"] ?? item["latitude"]; + recommendations.add(json.encode(item)); + } + return recommendations; + } + static Future> _getOMRecommendation(String query) async { var params = { 'name': query, @@ -74,13 +167,14 @@ class LocationService { var jsonbody = []; try { - var file = await cacheManager.getSingleFile(url.toString(), - key: "$query, open-meteo search", - headers: {'cache-control': 'private, max-age=120'}) - .timeout(const Duration(seconds: 4)); + var file = await cacheManager.getSingleFile(url.toString(), + key: "$query, open-meteo search", + headers: { + 'cache-control': 'private, max-age=120' + }).timeout(const Duration(seconds: 4)); var response = await file.readAsString(); jsonbody = jsonDecode(response)["results"]; - } catch(e) { + } catch (e) { return []; } @@ -109,8 +203,10 @@ class LocationService { /// Sanitizes the input query string by removing unsafe characters and limiting length static String _sanitizeQuery(String input) { - final safeInput = input.replaceAll(RegExp(r'[^\w\s,\-]'), ''); + final safeInput = input.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' '); final trimmedInput = safeInput.trim(); - return trimmedInput.length > 100 ? trimmedInput.substring(0, 100) : trimmedInput; + return trimmedInput.length > 100 + ? trimmedInput.substring(0, 100) + : trimmedInput; } -} \ No newline at end of file +} diff --git a/lib/services/preferences_service.dart b/lib/services/preferences_service.dart index 1c50b9b7..5d7dc20e 100644 --- a/lib/services/preferences_service.dart +++ b/lib/services/preferences_service.dart @@ -25,7 +25,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'color_service.dart'; Map> settingSwitches = { - 'Language' : [ + 'Language': [ 'English', //English 'Español', //Spanish 'Français', //French @@ -65,22 +65,26 @@ Map> settingSwitches = { 'Temperature': ['˚C', '˚F', 'K'], 'Precipitation': ['mm', 'in'], 'Wind': ['m/s', 'km/h', 'mph', 'kn'], - 'Time mode': ['12 hour', '24 hour'], 'Date format': ['mm/dd', 'dd/mm'], - 'Font size': ['normal', 'small', 'very small', 'big'], - - 'Color mode' : ['auto', 'light', 'dark'], - - 'Color source' : ['image', 'wallpaper', 'custom'], - 'Image source' : ['network', 'asset'], - 'Custom color': ['#c62828', '#ff80ab', '#7b1fa2', '#9575cd', '#3949ab', '#40c4ff', - '#4db6ac', '#4caf50', '#b2ff59', '#ffeb3b', '#ffab40',], - - 'Search provider' : ['weatherapi', 'open-meteo'], - - 'Layout' : ["sunstatus,rain indicator,hourly,alerts,radar,daily,air quality"], + 'Color mode': ['auto', 'light', 'dark'], + 'Color source': ['image', 'wallpaper', 'custom'], + 'Image source': ['network', 'asset'], + 'Custom color': [ + '#c62828', + '#ff80ab', + '#7b1fa2', + '#9575cd', + '#3949ab', + '#40c4ff', + '#4db6ac', + '#4caf50', + '#b2ff59', + '#ffeb3b', + '#ffab40', + ], + 'Layout': ["sunstatus,rain indicator,hourly,alerts,radar,daily,air quality"], 'Radar haptics': ["on", "off"], }; @@ -160,9 +164,12 @@ class ThemeProvider with ChangeNotifier { void loadTheme() { _brightness = PreferenceUtils.getString("Color mode", "auto"); switch (_brightness) { - case "light": _themeMode = ThemeMode.light; - case "dark": _themeMode = ThemeMode.dark; - case "auto": _themeMode = ThemeMode.system; + case "light": + _themeMode = ThemeMode.light; + case "dark": + _themeMode = ThemeMode.dark; + case "auto": + _themeMode = ThemeMode.system; } } @@ -181,14 +188,21 @@ class ThemeProvider with ChangeNotifier { void setBrightness(String brightness) { PreferenceUtils.setString("Color mode", brightness); switch (brightness) { - case "light": _themeMode = ThemeMode.light; _brightness = brightness; - case "dark": _themeMode = ThemeMode.dark; _brightness = brightness; - case "auto": _themeMode = ThemeMode.system; _brightness = brightness; + case "light": + _themeMode = ThemeMode.light; + _brightness = brightness; + case "dark": + _themeMode = ThemeMode.dark; + _brightness = brightness; + case "auto": + _themeMode = ThemeMode.system; + _brightness = brightness; } notifyListeners(); } - void changeColorSchemeToImageScheme(ColorScheme lightColorScheme, ColorScheme darkColorScheme) { + void changeColorSchemeToImageScheme( + ColorScheme lightColorScheme, ColorScheme darkColorScheme) { _colorSchemeLight = lightColorScheme; _colorSchemeDark = darkColorScheme; notifyListeners(); @@ -202,8 +216,7 @@ class ThemeProvider with ChangeNotifier { //null it so it falls back to the dynamic palettes _colorSchemeLight = null; _colorSchemeDark = null; - } - else if (_colorSource == "custom") { + } else if (_colorSource == "custom") { loadCustomColorScheme(); } notifyListeners(); @@ -211,8 +224,10 @@ class ThemeProvider with ChangeNotifier { void updateCustomColorFromHex() { _themeSeedColor = Color(getColorFromHex(_themeSeedColorHex)); - _colorSchemeLight = ColorScheme.fromSeed(seedColor: _themeSeedColor, brightness: Brightness.light); - _colorSchemeDark = ColorScheme.fromSeed(seedColor: _themeSeedColor, brightness: Brightness.dark); + _colorSchemeLight = ColorScheme.fromSeed( + seedColor: _themeSeedColor, brightness: Brightness.light); + _colorSchemeDark = ColorScheme.fromSeed( + seedColor: _themeSeedColor, brightness: Brightness.dark); } void setCustomColorScheme(String to) { @@ -235,11 +250,17 @@ class SettingsProvider with ChangeNotifier { bool _radarHapticsOn = true; - String _searchProvider = "weatherapi"; - String _imageSource = "network"; - List _layout = ["sunstatus", "rain indicator" ,"hourly", "alerts" ,"radar", "daily", "air quality"]; + List _layout = [ + "sunstatus", + "rain indicator", + "hourly", + "alerts", + "radar", + "daily", + "air quality" + ]; double _textScale = 1.0; @@ -265,8 +286,6 @@ class SettingsProvider with ChangeNotifier { bool get getRadarHapticsOn => _radarHapticsOn; - String get getSearchProvider => _searchProvider; - String get getImageSource => _imageSource; List get getLayout => _layout; @@ -292,7 +311,8 @@ class SettingsProvider with ChangeNotifier { } void _load() { - _weatherProvider = PreferenceUtils.getString("Weather provider", "open-meteo"); + _weatherProvider = + PreferenceUtils.getString("Weather provider", "open-meteo"); _tempUnit = PreferenceUtils.getString("Temperature", "˚C"); _windUnit = PreferenceUtils.getString("Wind", "m/s"); @@ -312,19 +332,29 @@ class SettingsProvider with ChangeNotifier { _imageSource = PreferenceUtils.getString("Image source", "network"); - _searchProvider = PreferenceUtils.getString("Search provider", "weatherapi"); - - _layout = PreferenceUtils.getStringList("Layout order", ["sunstatus", "rain indicator" ,"hourly", "alerts" ,"radar", "daily", "air quality"]); + _layout = PreferenceUtils.getStringList("Layout order", [ + "sunstatus", + "rain indicator", + "hourly", + "alerts", + "radar", + "daily", + "air quality" + ]); _textScale = PreferenceUtils.getDouble("Text scale", 1.0); _location = PreferenceUtils.getString("LastPlaceN", "New York"); _latLon = PreferenceUtils.getString("LastCord", "40.7128, -74.0060"); - _ongoingNotificationOn = PreferenceUtils.getBool("Ongoing notification", false); - _ongoingNotificationPlace = PreferenceUtils.getString("Ongoing place", "unknown"); - _ongoingNotificationLatLon = PreferenceUtils.getString("Ongoing latLon", "unknown"); - _ongoingNotificationProvider = PreferenceUtils.getString("Ongoing provider", "open-meteo"); + _ongoingNotificationOn = + PreferenceUtils.getBool("Ongoing notification", false); + _ongoingNotificationPlace = + PreferenceUtils.getString("Ongoing place", "unknown"); + _ongoingNotificationLatLon = + PreferenceUtils.getString("Ongoing latLon", "unknown"); + _ongoingNotificationProvider = + PreferenceUtils.getString("Ongoing provider", "open-meteo"); } void _loadLocale() { @@ -377,7 +407,8 @@ class SettingsProvider with ChangeNotifier { PreferenceUtils.setString("Time mode", to); _timeMode = to; notifyListeners(); - WidgetService.updateWidgetTimeFormat(to).then((_) => WidgetService.reloadWidgets()); + WidgetService.updateWidgetTimeFormat(to) + .then((_) => WidgetService.reloadWidgets()); } void setDateFormat(String to) { @@ -425,12 +456,6 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } - void setSearchProvider(String to) { - PreferenceUtils.setString("Search provider", to); - _searchProvider = to; - notifyListeners(); - } - void setWeatherProvider(String to) { PreferenceUtils.setString("Weather provider", to); _weatherProvider = to; @@ -448,4 +473,4 @@ class SettingsProvider with ChangeNotifier { _textScale = to; notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/services/timezone_service.dart b/lib/services/timezone_service.dart new file mode 100644 index 00000000..dcec486e --- /dev/null +++ b/lib/services/timezone_service.dart @@ -0,0 +1,78 @@ +/* +Copyright (C) <2026> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ + +import 'package:lat_lng_to_timezone/lat_lng_to_timezone.dart' as tzmap; +import 'package:timezone/data/latest_10y.dart' as timezone_data; +import 'package:timezone/timezone.dart' as timezone; + +class TimezoneService { + static bool _isInitialized = false; + + static DateTime getLocalTime(double lat, double lng, {DateTime? utcNow}) { + return localDateTimeFromUtc(lat, lng, utcNow ?? DateTime.now().toUtc()); + } + + static DateTime localDateTimeFromUtc( + double lat, double lng, DateTime utcTime) { + _ensureInitialized(); + + try { + final timezoneName = tzmap.latLngToTimezoneString(lat, lng); + final location = timezone.getLocation(timezoneName); + final localTime = timezone.TZDateTime.from(utcTime.toUtc(), location); + + return _withoutTimezone(localTime); + } catch (_) { + return approximateLocalDateTimeFromUtc(lng, utcTime); + } + } + + static DateTime approximateLocalDateTimeFromUtc( + double lng, DateTime utcTime) { + final offsetHours = approximateOffsetHours(lng); + final localTime = utcTime.toUtc().add(Duration(hours: offsetHours)); + + return _withoutTimezone(localTime); + } + + static int approximateOffsetHours(double lng) { + return (lng / 15).round().clamp(-12, 14).toInt(); + } + + static void _ensureInitialized() { + if (_isInitialized) { + return; + } + + timezone_data.initializeTimeZones(); + _isInitialized = true; + } + + static DateTime _withoutTimezone(DateTime time) { + return DateTime( + time.year, + time.month, + time.day, + time.hour, + time.minute, + time.second, + time.millisecond, + time.microsecond, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0dff68cb..3f68e904 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: "direct main" description: name: dynamic_system_colors - sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" + sha256: "6cda994363fe362e3c433e068af83feffc2c9a26ba0be7ecdb2b96f676d2dfd8" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.0" expressive_loading_indicator: dependency: "direct main" description: @@ -437,6 +437,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.0" + lat_lng_to_timezone: + dependency: "direct main" + description: + name: lat_lng_to_timezone + sha256: "149db00299362dd756271fd9c936e533618811214c130ca77ebfa3ccdfc6a593" + url: "https://pub.dev" + source: hosted + version: "0.2.0" latlong2: dependency: "direct main" description: @@ -529,18 +537,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_new_shapes: dependency: transitive description: @@ -553,10 +561,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -902,12 +910,12 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 diff --git a/pubspec.yaml b/pubspec.yaml index 829ec362..ec571d28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,8 @@ dependencies: google_fonts: ^6.2.1 workmanager: ^0.6.0 maplibre_gl: ^0.25.0 + timezone: ^0.10.1 + lat_lng_to_timezone: ^0.2.0 dev_dependencies: flutter_test: diff --git a/test/decode_mf_test.dart b/test/decode_mf_test.dart new file mode 100644 index 00000000..a4e85573 --- /dev/null +++ b/test/decode_mf_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:overmorrow/decoders/decode_mf.dart' as meteo_france; +import 'package:overmorrow/decoders/weather_data.dart'; + +const _forecastWindowEnd = 10800; + +Map _forecastAt(int timestamp) { + return { + 'dt': timestamp, + 'T': {'value': 12}, + 'weather': {'desc': 'Ciel clair'}, + 'rain': {'1h': 0}, + 'snow': {'1h': 0}, + 'wind': {'speed': 5, 'direction': 180}, + }; +} + +WeatherSunStatus _sunStatus() { + return WeatherSunStatus( + sunrise: DateTime(1970, 1, 1, 6), + sunset: DateTime(1970, 1, 1, 18), + sunstatus: 0.5, + ); +} + +Map _probabilities() { + return { + _forecastWindowEnd: { + 'dt': _forecastWindowEnd, + 'rain': {'3h': 40, '6h': 80}, + 'snow': {'3h': 0, '6h': 0}, + }, + }; +} + +void main() { + test('fills hourly precipitation probability from Meteo-France windows', () { + final hour = meteo_france.mfWeatherHourFromJson( + _forecastAt(3600), + _probabilities(), + null, + _sunStatus(), + ); + + expect(hour.precipProb, 40); + }); + + test('prefers the shortest matching Meteo-France probability window', () { + final hour = meteo_france.mfWeatherHourFromJson( + _forecastAt(_forecastWindowEnd), + _probabilities(), + null, + _sunStatus(), + ); + + expect(hour.precipProb, 40); + }); +} diff --git a/test/decode_mn_test.dart b/test/decode_mn_test.dart new file mode 100644 index 00000000..c6fce468 --- /dev/null +++ b/test/decode_mn_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:overmorrow/decoders/decode_mn.dart' as met_norway; + +Map _metNHour(String time) { + return { + 'time': time, + 'data': { + 'instant': { + 'details': { + 'air_temperature': 12.0, + 'wind_speed': 5.0, + 'wind_from_direction': 180.0, + 'ultraviolet_index_clear_sky': 1.0, + }, + }, + 'next_1_hours': { + 'summary': {'symbol_code': 'clearsky_day'}, + 'details': { + 'precipitation_amount': 0.0, + 'probability_of_precipitation': 20.0, + }, + }, + }, + }; +} + +Map _metNInstantOnlyHour(String time) { + return { + 'time': time, + 'data': { + 'instant': { + 'details': { + 'air_temperature': 12.0, + 'wind_speed': 5.0, + 'wind_from_direction': 180.0, + 'ultraviolet_index_clear_sky': 1.0, + 'relative_humidity': 60.0, + }, + }, + }, + }; +} + +void main() { + test('converts Met.no UTC timestamps to the forecast location timezone', () { + final hour = met_norway.metNWeatherHourFromJson( + _metNHour('2026-01-15T12:00:00Z'), + 28.6139, + 77.2090, + ); + + expect(hour.time.year, 2026); + expect(hour.time.month, 1); + expect(hour.time.day, 15); + expect(hour.time.hour, 17); + expect(hour.time.minute, 30); + }); + + test('keeps Met.no hours usable when next forecast summary is missing', () { + final hour = met_norway.metNWeatherHourFromJson( + _metNInstantOnlyHour('2026-01-15T12:00:00Z'), + 40.7128, + -74.0060, + ); + + expect(hour.condition, 'Clear Sky'); + expect(hour.precipMm, 0); + expect(hour.precipProb, isNull); + }); + + test('uses longer Met.no forecast blocks when next 1 hour is missing', () { + final item = _metNInstantOnlyHour('2026-01-15T12:00:00Z'); + item['data']['next_12_hours'] = { + 'summary': {'symbol_code': 'rain'}, + 'details': { + 'precipitation_amount': 2.5, + 'probability_of_precipitation': 70, + }, + }; + + final hour = met_norway.metNWeatherHourFromJson( + item, + 40.7128, + -74.0060, + ); + + expect(hour.condition, 'Rain'); + expect(hour.precipMm, 2.5); + expect(hour.precipProb, 70); + }); +} diff --git a/test/timezone_service_test.dart b/test/timezone_service_test.dart new file mode 100644 index 00000000..d9cf000e --- /dev/null +++ b/test/timezone_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:overmorrow/services/timezone_service.dart'; + +void expectLocalFields( + DateTime time, { + required int year, + required int month, + required int day, + required int hour, + int minute = 0, +}) { + expect(time.isUtc, isFalse); + expect(time.year, year); + expect(time.month, month); + expect(time.day, day); + expect(time.hour, hour); + expect(time.minute, minute); +} + +void main() { + test('converts Paris UTC time with summer DST offset', () { + final localTime = TimezoneService.localDateTimeFromUtc( + 48.8566, + 2.3522, + DateTime.utc(2026, 7, 1, 12), + ); + + expectLocalFields(localTime, year: 2026, month: 7, day: 1, hour: 14); + }); + + test('converts New York UTC time with winter standard offset', () { + final localTime = TimezoneService.localDateTimeFromUtc( + 40.7128, + -74.0060, + DateTime.utc(2026, 1, 15, 12), + ); + + expectLocalFields(localTime, year: 2026, month: 1, day: 15, hour: 7); + }); + + test('converts Kolkata UTC time with fractional offset', () { + final localTime = TimezoneService.localDateTimeFromUtc( + 22.5726, + 88.3639, + DateTime.utc(2026, 1, 15, 12), + ); + + expectLocalFields(localTime, + year: 2026, month: 1, day: 15, hour: 17, minute: 30); + }); + + test('uses longitude approximation fallback with clamped offsets', () { + final localTime = TimezoneService.approximateLocalDateTimeFromUtc( + 30, + DateTime.utc(2026, 1, 1, 23, 30), + ); + + expectLocalFields(localTime, + year: 2026, month: 1, day: 2, hour: 1, minute: 30); + expect(TimezoneService.approximateOffsetHours(240), 14); + expect(TimezoneService.approximateOffsetHours(-240), -12); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 00db5f9f..a7cf1679 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,14 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:overmorrow/main.dart'; +import 'package:overmorrow/decoders/decode_mf.dart' as meteo_france; +import 'package:overmorrow/decoders/weather_data.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + test('Meteo France decoder entry points are available', () { + expect(meteo_france.MfGetWeatherData, isNotNull); + expect(meteo_france.mfGetLightCurrentData, isNotNull); + expect(meteo_france.mfGetLightWindData, isNotNull); + expect(meteo_france.mfGetLightUvData, isNotNull); + expect(meteo_france.mfGetLightHourlyData, isNotNull); + expect(WeatherData.getFullData, isNotNull); }); }