В данной статье мы рассмотрим, каким образом можно создать консольное приложение, позволяющее получить погодные данные для выбранной локации и отобразить их в консоли, используя язык программирования dart и библиотеку weather_pack. Также научимся использовать сервис геокодирования местоположений и рассмотрим удобные классы, позволяющие конвертировать значения в нужные единицы измерения.

Пакет weather_pack был разработан, чтобы упростить доступ к онлайн сервису https://openweathermap.org/ (далее, OWM), а также предоставить удобные методы по работе с полученными данными. Следующее руководство будет придерживаться официальных рекомендаций по использованию (readme.md проекта), но с некоторыми бонусами.

Приступим!

Оглавление

Предисловие

Данный материал был создан, чтобы решить ряд следующих задач:

  • познакомиться с платформой habr в роли автора контента

  • рассказать, как создать консольное приложение с помощью языка программирования dart

  • рассмотреть возможности использования пакета weather_pack

Библиотека weather_pack не зависит от фреймворка flutter, и её можно использовать, например, в консольных приложениях dart. Именно так, создав консольное приложение, мы и протестируем данный пакет.

Подготовительный этап

Для начала, создадим наше dart-приложение. Перейдите в необходимую папку и запустите команду:

dart create --template=console weather_in_console

Флаг --template=console создает простенькое консольное приложение-шаблон.

Теперь необходимо добавить пакет в качестве зависимости в наш файл pubspec.yaml. Воспользуемся командой:

dart pub add weather_pack

Или же, добавим вручную:

dependencies:

	# из pub.dev
	weather_pack: ^<последняя_актуальная_версия>
	
	# или, если пакет скачан и находится в локальной папке
	weather_pack:
	  path: ./путь_до_пакета/weather_pack

  # или, прямиком из github
	weather_pack:
		git:
		  url: https://github.com/PackRuble/weather_pack.git
		  ref: master

После запускаем команду dart pub get. После всех манипуляций мы имеем такую структуру папок.

Подготовительный этап завершён, мы можем начинать пользоваться пакетом.

Получаем первые погодные данные

На данный момент в версии 0.0.2 есть два основных класса погоды: WeatherCurrent и WeatherOneCall. Вторая модель сложнее и включает в себя WeatherCurrent:

  1. WeatherCurrent - характеристики текущей погоды

  2. List<WeatherHourly> - список погодных моделей, различающихся на час. Включает в себя порядка 48 объектов, содержащих различные параметры.

  3. List<WeatherMinutely> - список моделей, характеризующихся минутной разницей и в качестве поля имеющие только precipitation - количество осадков в данное время.

  4. List<WeatherDaily> - список из 7 объектов, включающий обширный объем параметров, характеризующий каждый последующий день

  5. List<WeatherAlert> - погодные предупреждения. Поля: дата начала и дата окончания события, само событие и его описание. Ещё есть имя отправителя и теги.

Два основных типа моделей означает, что это разные запросы на сервер. И запрос для получения WeatherOneCall дороже (было так: 1000/день и 30000/месяц). На данный момент, я не могу найти эту информацию для количества запросов именно для One Call API 2.5 (ранее это api называлось "one call api 2.0" и не включало в себя 5-ти дневной исторической погоды. Более того, по вышеуказанной ссылке раньше мы попадали именно на версию 2.0, на api которой и основан данный пакет).

А сейчас появился тариф "one call api 3.0", который предлагает 1000 бесплатных запросов в день, но при превышении лимита - плOти. И вообще, прежде чем его использовать, необходимо подписаться на этот тариф, заполнив нижеуказанную форму и ... "Continue with Stripe" как бы намекает, что будет весело. Короче, бизнес и маркетинг, ничего личного.

Поэтому, наши базовые запросы будут выглядеть так:

/// Получить погоду [WeatherCurrent].
Future<WeatherCurrent> _getWeatherCurrent(PlaceGeocode city) async {
  return _wService.currentWeatherByLocation(
    latitude: city.latitude ?? 0.0,
    longitude: city.longitude ?? 0.0,
  );
}

/// Получить погоду [WeatherOneCall].
Future<WeatherOneCall> _getWeatherOneCall(PlaceGeocode city) async {
  return _wService.oneCallWeatherByLocation(
    latitude: city.latitude ?? 0.0,
    longitude: city.longitude ?? 0.0,
  );
}

Хочу обратить ваше внимание, что сервис OWM рекомендует использовать поиск погоды именно по координатам. Ранее это было возможно, используя город, однако:

Please use Geocoder API if you need automatic convert city names and zip-codes to geo coordinates and the other way around.

Please note that built-in geocoder has been deprecated. Although it is still available for use, bug fixing and updates are no longer available for this functionality.

Поэтому, в пакете имеется класс GeocodingService, который предоставляет два метода: получение списка местоположений по координатам (getLocationByCoordinates()) и по предполагаемому названию местоположения (getLocationByCityName()).

Самое время углубиться в архитектуру нашего мини-приложения.

Архитектура

Как таковая, она будет отсутствовать. Входная точка приложения это файл по пути bin\main.dart. Код в нём следующий:

import 'package:weather_in_console/weather_in_console.dart' as service_owm;

Future<void> main(List<String> arguments) async {
  await service_owm.getWeather();
}

Весь код логики будет (и должен) содержаться в файле по пути lib/weather_in_console.dart . Всё сделаем приватным через _ и определим одну единственную публичную функцию getWeather().

Наше приложение будет работать согласно блок-схеме:

Достаточно длинная лапша, из-за ромбиков-условий...
 Создано с помощью mermaid
Создано с помощью mermaid

Пишем код

Будем поэтапно, согласно вышеуказанной схеме, писать код. Все действия происходят в файле lib/weather_in_console.dart.

Для начала определяем нашу единственную публичную функцию:

import 'dart:io';
import 'package:weather_pack/weather_pack.dart';

// глобальная область видимости

/// Функция получения погоды
Future<void> getWeather() async {
  stdout.writeln(' ✨ Добро пожаловать в погодный сервис! ✨ \n');

  // локальная область видимости функции getWeather()

}

В локальной области видимости функции getWeather() будут происходить все основные манипуляции и вызовы других, непубличных функций. Условимся называть именно эту область локальной.

Для начала, необходимо проверить api-ключ на актуальность. Моделируем вот этот участок:

В глобальной области видимости определяем приватную функцию:

Future<void> _checkApiKey(String apiKey) async {
  final bool isCorrectApiKey = await OWMApiTest().isCorrectApiKey(_apiKey);

  if (isCorrectApiKey) {
    stdout.writeln(' ???? Ключ подходит для этого замка ^_~');
  } else {
    stdout.writeln(' ❌ Api-ключ не подходит!');
    exit(1);
  }
}

Для проверки ключа используем пакетный метод OWMApiTest.isCorrectApiKey(). Если ключ проходит проверку, продолжаем выполнение программы, иначе, прерываем выполнение командой exit(1). Из документации:

Although you can use any number for an exit code, by convention, the codes in the table below have the following meanings:
0 - Success, 1 - Warnings, 2 - Errors

Теперь вызовем эту функцию в локальной области:

// If you do not use the `--define="API_WEATHER=YOUR_APIKEY"` flag,
//  provide the key here instead of 'null'
const String _apiKey = null ?? String.fromEnvironment('API_WEATHER');

Future<void> getWeather() async {
	...
  await _checkApiKey(_apiKey);
}

Небольшое отступление. Хочу обратить ваше внимание на то, что хранить секретные ключи и прочие приватные данные в коде я не рекомендую! В данном случае вы можете указать ваш api-ключ, полученный в сервисе OWM (зарегистрируйтесь на сайте openweathermap.org и получите ключ в личном кабинете на вкладке API keys. Это бесплатно), вместо null, однако, я рекомендую сделать иначе и воспользоваться const String.fromEnvironment().

Если вы запускаете приложение в терминале, то воспользуйтесь спец. флагом:

dart run --define="API_WEATHER=YOUR_APIKEY" bin/main.dart

Или же, чтобы использовать удобную вкладку Run, в Android Studio вы можете сделать так:

Есть неплохая статья на эту тему здесь.

Далее, реализация вот этого участка схемы:

будет выглядеть так:

import 'package:interact/interact.dart';

//...

/// Получить местоположение, по которому пользователь хочет узнать погоду.
String _inputCity() {
  final input = Input(
    prompt: 'Ваше местоположение?',
  ).interact();

  if (input.isEmpty) {
    final isResume = Confirm(
      prompt: 'Местоположение не указано. Попробовать ещё раз?',
      defaultValue: true,
      waitForNewLine: true, // мы хотим, чтобы пользователь подтвердил действие клавишей enter
    ).interact();

    return isResume ? _inputCity() : exit(0);
  } else {
    return input;
  }
}

Да, чтобы принимать пользовательские вводы я решил использовать дополнительный пакет, облегчающий работу с командной строкой. Пакет называется interact: ^2.1.1. Позже, он нам ещё пригодится для реализации более сложных вещей.

После того, как пользователем было указано желаемое местоположение, необходимо это место преобразовать в координаты. На помощь приходит встроенный метод GeocodingService.getLocationByCityName() из нашего пакета weather_pack.

В глобальной области видимости определяем приватную переменную сервиса геокодирования, в конструкторе которого необходимо указать api-ключ:

final _gService = GeocodingService(_apiKey);

и используем этот сервис, чтобы получить список мест, похожих на желаемое:

Future<PlaceGeocode?> _selectCity(String desiredPlace) async {
  final spinner = Spinner(
    icon: ' ???? ',
    rightPrompt: (done) => done
        ? 'Вы ввели местоположение'
        : 'Получаем список доступных местоположений...',
  ).interact();

  final List<PlaceGeocode> places =
      await _gService.getLocationByCityName(desiredPlace);

  spinner.done();

  if (places.isEmpty) {
    stdout.writeln('Но "$desiredPlace" не найдено <(_ _)>');
    return null;
  }

  final index = Select(
    prompt: 'Выберите местоположение из списка?',
    options: places.map((PlaceGeocode place) {
      final result = StringBuffer();

      if (place.countryCode != null) {
        result.write('${place.countryCode}: ');
      }

      if (place.state != null) {
        result.write('${place.state}, ');
      }

      if (place.name != null) {
        result.write('${place.name}');
      }

      return result.toString();
    }).toList(),
  ).interact();

  return places[index];
}

Методы Spinner и Select из пакета interact

Используем Spinner, чтобы сделать более дружелюбным наш интерфейс и также показать, что сейчас осуществляется запрос. Как только запрос выполнен, завершаем Spinner командой spinner.done(). Далее, возвращаем null, если похожих мест не найдено. Иначе, предлагаем пользователю выбрать наиболее похожее место из списка найденных мест. Кстати, метод getLocationByCityName() принимает необязательный параметр limit, чтобы указать максимальное количество найденных мест. По умолчанию указан максимум - не более 5 мест (в соответствии с api OWM).

В локальной функции мы сделаем трюк - повторим весь процесс, если сервис геокодинга возвращает пустой список (т.е. null):

Future<void> getWeather() async {

  // ...

  PlaceGeocode? city;
  do {
    final String desiredPlace = _inputCity();
    city = await _selectCity(desiredPlace);
  } while (city == null);

}

Что ж, после того, как желаемое место было найдено и выбрано, предложим пользователю выбрать тип получаемых погодных данных. В блок схеме:

В коде:

enum _TypeWeather {
  hourly('Почасовая погода'),
  current('Текущая погода'),
  daily('Погода на 7 дней'),
  alerts('Погодные предупреждения'),
  all('Все данные');

  const _TypeWeather(this.name);

  final String name;
}

/// Выбрать тип погодных данных.
_TypeWeather _selectTypeWeather() {
  final index = Select(
    prompt: 'Какие погодные данные предоставить?',
    options: _TypeWeather.values.map((e) => e.name).toList(),
    initialIndex: 1,
  ).interact();

  return _TypeWeather.values[index];
}

В локальной функции:

Future<void> getWeather() async {

  //...

  final _TypeWeather typeWeather = _selectTypeWeather();
  await _getWeather(city, typeWeather);
}

В функции _getWeather() мы получаем погоду и отображаем результаты в консоли. Мы также помним, что тариф WeatherService.currentWeatherByLocation() дешевле, поэтому, если мы выбираем _TypeWeather.current, то делаем именно дешевый запрос (хотя, как мы и помним, модель WeatherOneCall включает в себя WeatherCurrent).

/// Получить погоду и распечатать результаты.
Future<void> _getWeather(PlaceGeocode city, _TypeWeather typeWeather) async {
  final gift = Spinner(
    icon: ' ???? ',
    rightPrompt: (done) =>
        done ? 'Вот награда за терпение' : 'Получаем погодные данные...',
  ).interact();

  late WeatherCurrent current;
  late WeatherOneCall oneCall;

  if (typeWeather == _TypeWeather.current) {
    current = await _getWeatherCurrent(city);
  } else {
    oneCall = await _getWeatherOneCall(city);
  }

  gift.done();

  switch (typeWeather) {
    case _TypeWeather.hourly:
      _printHourly(oneCall.hourly);
      break;
    case _TypeWeather.current:
      _printCurrent(current);
      break;
    case _TypeWeather.daily:
      _printDaily(oneCall.daily);
      break;
    case _TypeWeather.alerts:
      _printAlerts(oneCall.alerts);
      break;
    case _TypeWeather.all:
      _printAlerts(oneCall.alerts);
      _printHourly(oneCall.hourly);
      _printDaily(oneCall.daily);
      break;
  }
}

final _wService = WeatherService(_apiKey, language: WeatherLanguage.russian);

/// Получить погоду [WeatherCurrent].
Future<WeatherCurrent> _getWeatherCurrent(PlaceGeocode city) async {
  return _wService.currentWeatherByLocation(
    latitude: city.latitude ?? 0.0,
    longitude: city.longitude ?? 0.0,
  );
}

/// Получить погоду [WeatherOneCall].
Future<WeatherOneCall> _getWeatherOneCall(PlaceGeocode city) async {
  return _wService.oneCallWeatherByLocation(
    latitude: city.latitude ?? 0.0,
    longitude: city.longitude ?? 0.0,
  );
}

Конструктор WeatherService принимает необязательный параметр language, чтобы предоставить некоторые погодные данные на выбранном языке, например, WeatherCurrent.weatherDescription, а также PlaceGeocode.localNames - map, в которой содержатся названия местоположения на определенном языке. На данный момент api предоставляет 47 языков.

Функции, такие как _printDaily() отображают погодные данные в консоли. В пакете weather_pack есть удобные методы конвертации и отображения погодных данных. Посмотрим, как они работают на примере _printCurrent():

void _printCurrent(WeatherCurrent current) {
  final DateTime? date = current.date;

  const pressureUnits = Pressure.mmHg;
  const tempUnits = Temp.celsius;
  const speedUnits = Speed.kph;

  final temp = tempUnits.valueToString(current.temp!, 2);
  final tempFeelsLike = tempUnits.valueToString(current.tempFeelsLike!, 2);

  final windSpeed = speedUnits.valueToString(current.windSpeed!, 1);
  final windGust = speedUnits.valueToString(current.windGust ?? 0.0, 2);

  stdout.write('''
=====Сегодня=====
${date?.day}.${date?.month}.${date?.year} ${date?.hour}ч:${date?.minute}м
${current.weatherDescription}
Температура $temp ${tempUnits.abbr}, Ощущается как $tempFeelsLike ${tempUnits.abbr})
Скорость ветра - $windSpeed ${speedUnits.abbr}, порывы до $windGust ${speedUnits.abbr}
Направление ветра: ${SideOfTheWorld.fromDegrees(current.windDegree!).name}
Давление  ( ${pressureUnits.valueToString(current.pressure!)} ${pressureUnits.abbr} )
Влажность ( ${current.humidity} % )
Ультрафиолетовый индекс ( ${current.uvi} )
Восход и закат солнца   ( ${current.sunrise} --> ${current.sunset} )
=================
''');
}

Весь список доступных методов можно увидеть здесь.

Выбираем единицы измерения давления, температуры и скорости. У каждого перечисления есть два метода конвертации: value() и valueToString(). Если сказать коротко, то метод valueToString() нужен для корректного отображения данных в интерфейсе (чтобы точно контролировать количество знаков после запятой), а метод value() — для точных вычислений. Каждый из этих методов имеет позиционный параметр int precision, чтобы указать количество значимых знаков после запятой.

Этот код из "readmi" хорошо описывает суть данных методов:

void worksTempUnits({
  double temp = 270.78, // ex. received from [WeatherCurrent.temp]
  int precision = 3,
  Temp unitsMeasure = Temp.celsius,
}) {
  // The default temperature is measured in Kelvin of the `double` type.
  // We need the temperature to be displayed in Celsius to 3 decimal places

  print(unitsMeasure.value(temp, precision)); // `-2.37` type `double`
  print(unitsMeasure.valueToString(temp, precision)); // `-2.370` type `String`

  // if precision is 0:
  print(unitsMeasure.value(temp, 0)); // `-2.0` type `double`
  print(unitsMeasure.valueToString(temp, 0)); // `-2` type `String`
}

Ещё примечательный факт, что направление ветра из api приходит в градусах. С помощью метода SideOfTheWorld.fromDegrees() можно перевести градусы в сторону света.

Кажется, остался последний момент в нашей блок-схеме:

После преобразований наша локальная функция будет выглядеть так:

Future<void> getWeather() async {
  stdout.writeln(' ✨ Добро пожаловать в погодный сервис! ✨ \n');

  await _checkApiKey(_apiKey);

  bool isRepeat = true;

  do {
    PlaceGeocode? city;
    do {
      final String desiredPlace = _inputCity();
      city = await _selectCity(desiredPlace);
    } while (city == null);

    final _TypeWeather typeWeather = _selectTypeWeather();

    await _getWeather(city, typeWeather);

    isRepeat = _repeat();
  } while (isRepeat == true);

  exit(0);
}

bool _repeat() {
  return Confirm(
    prompt: 'Узнать погоду другого местоположения?',
    defaultValue: true,
    waitForNewLine: true,
  ).interact();
}

После отображения данных в консоли, мы предложим пользователю узнать погоду из другого местоположения. Если он откажется, мы завершим наше приложение.

Кажется, блок-схема подошла к Концу.

Компиляция в .exe

А почему бы и нет? Это достаточно просто сделать следующей командой:

dart compile exe bin/main.dart --define="API_WEATHER=YOUR_APIKEY"

И мы получим .exe файл, который можно передавать друзьям и знакомым. С оговоркой:

The exe subcommand has some known limitations:

No cross-compilation support (issue 28617)

The compiler can create machine code only for the operating system on which you’re compiling. To create executables for macOS, Windows, and Linux, you need to run the compiler three times. You can also use a continuous integration (CI) provider that supports all three operating systems.

Подробнее об имеющихся возможностях компиляции, а также о полезных флагах (есть крутой флаг управления оптимизацией) смотрите в официальной документации здесь.


Послесловие

Результат работы в гифке:

Есть небольшое разочарование в том, что в windows терминалах (и в cmd и в powershell) я не смог увидеть красивых эмодзи, которые использовал в коде. Однако, через новомодный terminal проблема решена.

Также, хочу обратить внимание, что мы не можем использовать кириллицу; соответственно, русскими буковками написать нужный нам город, например Омск, не получится. Приходится писать вот так: Omsk. Всё дело в данной проблеме, которая до сих пор не имеет очевидного решения.

Ремарка к кодовой базе:

  1. Допущения в области проверки на null (например, city.latitude ?? 0.0 или current.temp!) существуют только потому, что автор не ставит перед собой задачи корректно их обрабатывать.

  2. Полностью отсутствует обработка ошибок, однако, в weather_pack существует класс OwmApiException, призванный помочь с некоторыми ошибками api. Но, например, TimeoutException - это дело рук пользующегося пакетом.

  3. Отсутствующая адекватная архитектура (мы не можем назвать адекватной архитектурой напихивание разнопарных функций в один файл) приложения говорит только о том, что весь код лежит в папке example (и это правда).

Ссылка на репозиторий данного проекта.

Спасибо за прочтение данной статьи. Буду рад комментариям.

Пользуясь случаем, поздравляю хабровчан с Новым годом! ????

Комментарии (0)