Инструкция: как создать приложение для просмотра погоды на Flutter

Привет! В этой статье я познакомлю вас с кроссплатформенной разработкой приложений на Flutter.

Я покажу, как работать с сетью, расскажу, что такое BLoC, и на примере получения геолокации покажу, как работать с нативной частью приложения. Я подразумеваю, что у вас уже есть какие-то навыки программирования и не буду объяснять базовые вещи.

Инструкция: как создать приложение для просмотра погоды на Flutter

Для тех кто не хочет читать много текста есть ссылка на репозиторий GitHub.

Как примерно будет происходить наша разработка
Как примерно будет происходить наша разработка

0. Intro

Для начала давайте установим Flutter. Как это сделать – вы можете узнать об этом на официальном сайте Flutter.

1. Getting started

Инициализируем стартовый шаблон приложения с помощью CLI Flutter. Для этого выполним в терминале:

$ flutter create WeatherApp

Если все прошло успешно вы увидите примерно такое сообщение:

All done! [✓] Flutter: is fully installed. (Channel stable, 1.20.3, on Mac OS X 10.15.5 19F101, locale ru-RU) [!] Android toolchain - develop for Android devices: is partially installed; more components are available. (Android SDK version 29.0.3) [✓] Xcode - develop for iOS and macOS: is fully installed. (Xcode 11.7) [✓] Android Studio: is fully installed. (version 4.0) [!] VS Code: is partially installed; more components are available. (version 1.48.2) [!] Connected device: is not available. Run "flutter doctor" for information about installing additional components. In order to run your application, type: $ cd WeatherApp $ flutter run Your application code is in WeatherApp/lib/main.dart.

flutter doctor – утилита для проверки, что Flutter установлен правильно

Проверим, что всё работает нормально:

  • Открываем проект в Android Studio. Обратите внимание, что для работы с Flutter в Android Studio нужно установить плагин flutter.
  • Запускаем эмулятор или подключаем реальное устройство.
  • Запускаем проект Run → Debug.

2. Устанавливаем необходимые зависимости

Как и любой современный фреймворк Flutter имеет свой менеджер пакетов – Pub. Для того чтобы установить необходимые зависимости давайте добавим их в файл pubspec.yaml в конец секции dependecies:

dependencies: ... http: ^0.12.2 bloc: ^6.0.0 flutter_bloc: ^6.0.0 equatable: ^1.0.0 geolocator: ^6.0.0

После чего нам необходимо их установить. Для этого выполним в терминале команду:

$ flutter pub get

3. Сервис для работы с API

Информацию о погоде мы будем получать с сервиса OpenWeatherMap. Для начала вам необходимо зарегистрироваться, чтобы получить ключ для работы с API на почту. Давайте опишем сервис для работы с API:

// lib/services/WeatherService.dart class WeatherService { static String _apiKey = "Здесь должен быть ваш токен"; static Future<Weather> fetchCurrentWeather({query, String lat = "", String lon =""}) async { var url = 'http://api.openweathermap.org/data/2.5/weather?q=$query&lat=$lat&lon=$lon&appid=$_apiKey&units=metric'; final response = await http.post(url); if (response.statusCode == 200) { return Weather.fromJson(json.decode(response.body)); } else { throw Exception('Failed to load weather'); } } static Future<List<Weather>> fetchHourlyWeather({String query, String lat = "", String lon =""}) async { var url = 'http://api.openweathermap.org/data/2.5/forecast?q=$query&lat=$lat&lon=$lon&appid=$_apiKey&units=metric'; final response = await http.post(url); if (response.statusCode == 200) { final jsonData = json.decode(response.body); final List<Weather> data = (jsonData['list'] as List<dynamic>) .map((item) { return Weather.fromJson(item); }).toList(); return data; } else { throw Exception('Failed to load weather'); } } }

У нас будут два метода fetchCurrentWeather – метод получения текущей погоды и fetchHourlyWeather – метод получения погоды по часам.

Опишем модель данных о погоде:

// lib/models/Weather.dart class Weather { final String cityName; final int temperature; final String iconCode; final String description; final DateTime time; Weather( {this.cityName, this.temperature, this.iconCode, this.description, this.time}); factory Weather.fromJson(Map<String, dynamic> json) { return Weather( cityName: json['name'], temperature: double.parse(json['main']['temp'].toString()).toInt(), iconCode: json['weather'][0]['icon'], description: json['weather'][0]['main'], time: DateTime.fromMillisecondsSinceEpoch(json['dt'] * 1000) ); } }

В Dart асинхронные запросы всегда возвращаются в виде Future. Если говорить совсем коротко, то это данные которые будут доступны «в будущем». То есть, мы выполнили запрос, но, для того, чтобы клиент получил ответ от сервера должно пройти какое-то время, и чтобы интерфейс пользователя не блокировался мы должны обработать этот ответ специальным образом, таким как BLoC.

5. BLoC

Существует множество разных паттернов отделения бизнес-логики от UI компонента: MVC, MVP, MVVM и так далее. Во Flutter чаще всего принято использовать BLoC, что расшифровывается как Business logic component. Суть этих «блоков», чтобы сделать event-driven архитектуру приложения. Для начала опишем список состояний компонента с погодой:

// lib/states/WeatherState.dart abstract class WeatherState extends Equatable { const WeatherState(); @override List<Object> get props => []; } class WeatherInitial extends WeatherState {} class WeatherLoadInProgress extends WeatherState {} class WeatherLoadSuccess extends WeatherState { final Weather weather; final List<Weather> hourlyWeather; const WeatherLoadSuccess( {@required this.weather, @required this.hourlyWeather}) : assert(weather != null); @override List<Object> get props => [weather]; } class WeatherLoadFailure extends WeatherState {}

Мы описали 4 состояния:

  1. WeatherInitial – когда не происходит ничего
  2. WeatherLoadInProgress – когда мы загружаем данные о погоде
  3. WeatherLoadSuccess – когда данные загружены успешно
  4. WeatherLoadFailure – когда произошла какая-то ошибка

Далее нам необходимо описать события которые будет обрабатывать BLoC:

// lib/events/WeatherEvent.dart abstract class WeatherEvent extends Equatable { const WeatherEvent(); } class WeatherRequested extends WeatherEvent { final String city; final String lat; final String lon; const WeatherRequested({this.city = "", this.lat = "", this.lon = ""}) : assert(city != null); @override List<Object> get props => [city]; }

Сейчас нам достаточно одного события – WeatherRequested. Это событие для того, чтобы BLoC понимал что необходимо запрашивать данные о погоде по названию города.

Опишем сам BLoC:

// lib/bloc/WeatherBloc.dart class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {}

Для реализации BLoC мы выбрали библиотеки bloc и flutter_bloc. Как видим наш «блок» это просто класс который наследуется от дженерика Bloc из библиотеки Bloc. «Блок» принимает два типа:

  • WeatherEvent – события которые он будет обрабатывать
  • WeatherState – состояния в которых он может быть

Теперь нам необходимо реализовать метод mapEventToState для обработки событий в «блоке»:

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> { @override Stream<WeatherState> mapEventToState(WeatherEvent event) async* { if (event is WeatherRequested) { yield WeatherLoadInProgress(); try { final Weather weather = await WeatherService.fetchCurrentWeather(query: event.city); final List<Weather> hourlyWeather = await WeatherService.fetchHourlyWeather(query: event.city); yield WeatherLoadSuccess( weather: weather, hourlyWeather: hourlyWeather); } catch (_) { yield WeatherLoadFailure(); } } } }

И добавим в конструктор блока стандартное состояние:

final String cityName; WeatherBloc(this.cityName) : super(null) { add(WeatherRequested(city: cityName)); }

Метод add() добавляет событие в блок и это событие обрабатывается методом mapEventToState.

Вот так выглядит наш полный «блок»:

// lib/bloc/WeatherBloc.dart import 'package:WeatherApp/events/WeatherEvent.dart'; import 'package:WeatherApp/models/Weather.dart'; import 'package:WeatherApp/services/WeatherService.dart'; import 'package:WeatherApp/states/WeatherState.dart'; import 'package:bloc/bloc.dart'; class WeatherBloc extends Bloc<WeatherEvent, WeatherState> { final String cityName; WeatherBloc(this.cityName) : super(null) { add(WeatherRequested(city: cityName)); } @override Stream<WeatherState> mapEventToState(WeatherEvent event) async* { if (event is WeatherRequested) { yield WeatherLoadInProgress(); try { final Weather weather = await WeatherService.fetchCurrentWeather(query: event.city); final List<Weather> hourlyWeather = await WeatherService.fetchHourlyWeather(query: event.city); yield WeatherLoadSuccess( weather: weather, hourlyWeather: hourlyWeather); } catch (_) { yield WeatherLoadFailure(); } } } }

6. Интерфейс

Сделаем карточку погоды. Карточка должна отображать температуру, иконку и произвольный заголовок.

// lib/components/WeatherCard.dart class WeatherCard extends StatelessWidget { final String title; final int temperature; final String iconCode; final double temperatureFontSize; final double iconScale; const WeatherCard({Key key, this.title, this.temperature, this.iconCode, this.temperatureFontSize = 32, this.iconScale = 2}) : super(key: key); @override Widget build(BuildContext context) { return Container( child: Padding( // Добавляем отступы padding: EdgeInsets.all(8), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, // Выравниваем по центру children: [ Text(this.title), Image.network("http://openweathermap.org/img/wn/${this.iconCode}@2x.png", scale: this.iconScale), Text( '${this.temperature}°', style: TextStyle(fontSize: this.temperatureFontSize, fontWeight: FontWeight.bold), ), ], ), ), ), ); } }

Нам также понадобится компонент для отображения погоды по часам:

// lib/components/WeatherHours.dart class HourlyWeather extends StatelessWidget { final List<Weather> hourlyWeather; const HourlyWeather({Key key, this.hourlyWeather}) : super(key: key); @override Widget build(BuildContext context) { return Container( height: 200.0, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: hourlyWeather.length, itemBuilder: (context, i) { return WeatherCard( title: '${hourlyWeather[i].time.hour}:${hourlyWeather[i].time.minute}0', temperature: hourlyWeather[i].temperature.toInt(), iconCode: hourlyWeather[i].iconCode, temperatureFontSize: 20, ); })); } }

Сделаем компонент для главной страницы, который будет в себя включать виджет с текущей погодой и горизонтальный скролл с погодой по часам:

// lib/components/MainScreenWrapper.dart class MainScreenWrapper extends StatelessWidget { final Weather weather; final List<Weather> hourlyWeather; const MainScreenWrapper({Key key, this.weather, this.hourlyWeather}) : super(key: key); @override Widget build(BuildContext context) { return Container( child: Column(children: [ Text( weather.cityName, style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), ), Text( '${weather.description}'), Spacer(), WeatherCard( title: "Now", temperature: weather.temperature, iconCode: weather.iconCode, temperatureFontSize: 64, iconScale: 1, ), Spacer(), HourlyWeather(hourlyWeather: this.hourlyWeather) ]), ); } }

7. Bloc + UI = <3

Давайте соединим наш BLoC и UI. Входная точка в приложение в Dart/Flutter – функция main в файле lib/main.dart. Отсюда будет происходить запуск нашего приложения.

// lib/main.dart void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( primaryColorDark: Colors.white, primaryColor: Colors.white, ), home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({Key key}) : super(key: key); @override State<StatefulWidget> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => WeatherBloc('Berlin'), child: BlocBuilder<WeatherBloc, WeatherState>( builder: (context, state) { if (state is WeatherLoadSuccess) { return Scaffold( appBar: AppBar( elevation: 0, backgroundColor: Color.fromRGBO(0, 0, 0, 0), actions: [ IconButton( icon: Icon(Icons.search), onPressed: () { showSearch( context: context, delegate: MySearchDelegate((query) { BlocProvider.of<WeatherBloc>(context).add(WeatherRequested(city: query)); })); }, ) ], ), body: Padding( padding: EdgeInsets.only(top: 64), child: MainScreenWrapper( weather: state.weather, hourlyWeather: state.hourlyWeather), ), ); } return Scaffold( body: Center( child: CircularProgressIndicator(), ), ); }, ), ); } }

Есть несколько способов работы с BLoC, мы будем использовать BlocProvider. Разберем несколько строк кода:

Scaffold – компонент для стандартного интерфейса MaterialDesign: Шапка/Тело/Навигация и FloatingButton.

create: (context) => WeatherBloc('Berlin') – здесь мы создаем наш объект «блока» и передаем туда значение по умолчанию.

child: BlocBuilder<WeatherBloc, WeatherState>( builder: (context, state) {

Под капотом BlocBuilder это обычный StatefulWidget. Он имеет колбек builder, который вызывается каждый раз когда внутри WeatherBloc вызывается метод mapEventToState и возвращает новое значение, тем самым заставляя компонент перерисовываться. Внутри builder у нас есть проверка состояния компонента, которая будет рисовать компонент только если данные получены успешно:

if (state is WeatherLoadSuccess) {

8. SearchDelegate

У Flutter есть метод showSearch который открывает окно поиска по гайдлайнам MaterialDesign.

В шапке Scaffold мы добавили иконку поиска по клику на которую вызывается окно поиска:

return BlocProvider( create: (context) => WeatherBloc('Berlin'), child: BlocBuilder<WeatherBloc, WeatherState>( builder: (context, state) { if (state is WeatherLoadSuccess) { return Scaffold( appBar: AppBar( elevation: 0, backgroundColor: Color.fromRGBO(0, 0, 0, 0), actions: [ IconButton( icon: Icon(Icons.search), onPressed: () { showSearch( context: context, delegate: MySearchDelegate((query) { BlocProvider.of<WeatherBloc>(context).add(WeatherRequested(city: query)); })); }, ) ], ),

Открытие окна происходит с помощью метода showSearch, который принимает в себя текущий контекст приложения и класс SearchDelegate который будет обрабатывать результат поиска. Давайте напишем свой SearchDelegate:

class MySearchDelegate extends SearchDelegate { String selectedResult; final Function callback; MySearchDelegate(this.callback); @override List<Widget> buildActions(BuildContext context) { return [ IconButton( icon: Icon(Icons.close), onPressed: () { query = ""; }, ), ]; } @override Widget buildLeading(BuildContext context) { return IconButton( icon: Icon(Icons.arrow_back), onPressed: () { Navigator.pop(context); }, ); } @override Widget buildResults(BuildContext context) { return Container( child: Center( child: Text(selectedResult), ), ); } @override void showResults(BuildContext context) { selectedResult = query; callback(query); close(context, query); } @override Widget buildSuggestions(BuildContext context) { List<String> searchResults = ["Helsinki", "Moscow", "Berlin", "New York", "Saint Petersburg", query].where((element) => element.contains(query)).toList(); return ListView.builder( itemCount: searchResults.length, itemBuilder: (context, index) { return ListTile( title: Text(searchResults[index]), onTap: () { selectedResult = searchResults[index]; callback(selectedResult); Navigator.pop(context); }, ); }, ); } }

Здесь у нас четыре метода:

  1. buildActions – возвращает компонент, который отображается справа строки поиска. У нас это кнопка удаление текущего ввода.
  2. buildLeading – возвращает компонент, который отображается слева шапки (например, стрелка назад).
  3. buildSuggestions – обрабатывает то, что вводит пользователь.
  4. buildResults – вызывается, когда был вызван метод showResults.

Мы также описали колбек который будет вызываться когда пользователь выбрал какой-то город или нажал поиск на клавиатуре:

final Function callback;

В lib/main.dart мы описали его так:

MySearchDelegate((query) { BlocProvider.of<WeatherBloc>(context).add(WeatherRequested(city: query)); })

Мы получаем результат ввода пользователя и кидаем событие WeatherRequested для WeatherBloc после чего происходит ререндер всего экрана.

Посмотрим на результат:

9. Определение текущей геолокации пользователя

Для того, чтобы определить геолокацию пользователя мы используем библиотеку geolocator.

Для того, чтобы она заработала нам необходимо добавить специальные разрешения в нативные части приложения под iOS и Android. О том как это сделать описано в документации библиотеки geolocator.

Для начала добавим новое событие в файл lib/events/WeatherEvent.dart:

// lib/events/WeatherEvent.dart class WeatherCurrentPositionRequested extends WeatherEvent { const WeatherCurrentPositionRequested() : super(); @override List<Object> get props => []; }

Отредактируем файл lib/bloc/WeatherBloc.dart.

Создадим метод _newWeatherRequested:

Stream<WeatherState> _newWeatherRequested(WeatherRequested event) async* { yield WeatherLoadInProgress(); try { final Weather weather = await WeatherService.fetchCurrentWeather( query: event.city, lon: event.lon, lat: event.lat); final List<Weather> hourlyWeather = await WeatherService.fetchHourlyWeather( query: event.city, lon: event.lon, lat: event.lat); yield WeatherLoadSuccess(weather: weather, hourlyWeather: hourlyWeather); } catch (_) { yield WeatherLoadFailure(); } }

Также сделаем новый обработчик события для определения текущей локации:

Stream<WeatherState> _newWeatherCurrentPositionRequested() async* { LocationPermission permission = await checkPermission(); if (permission == LocationPermission.whileInUse || permission == LocationPermission.always) { Position lastKnownPosition = await getLastKnownPosition(); if(lastKnownPosition != null) { add(WeatherRequested( lat: lastKnownPosition.latitude.toString(), lon: lastKnownPosition.longitude.toString())); } else { Position position = await getCurrentPosition(desiredAccuracy: LocationAccuracy.high); add(WeatherRequested( lat: position.latitude.toString(), lon: position.longitude.toString())); } } else { await requestPermission(); add(WeatherCurrentPositionRequested()); } }

Здесь мы проверяем права на локацию, если прав нет, то просто запрашиваем.

И обновим mapEventToState:

@override Stream<WeatherState> mapEventToState(WeatherEvent event) async* { if (event is WeatherRequested) { yield* _newWeatherRequested(event); } if (event is WeatherCurrentPositionRequested) { yield* _newWeatherCurrentPositionRequested(); } }

И весь наш новый lib/bloc/WeatherBloc.dart:

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> { WeatherBloc() : super(null) { add(WeatherCurrentPositionRequested()); } @override Stream<WeatherState> mapEventToState(WeatherEvent event) async* { if (event is WeatherRequested) { yield* _newWeatherRequested(event); } if (event is WeatherCurrentPositionRequested) { yield* _newWeatherCurrentPositionRequested(); } } Stream<WeatherState> _newWeatherRequested(WeatherRequested event) async* { yield WeatherLoadInProgress(); try { final Weather weather = await WeatherService.fetchCurrentWeather( query: event.city, lon: event.lon, lat: event.lat); final List<Weather> hourlyWeather = await WeatherService.fetchHourlyWeather( query: event.city, lon: event.lon, lat: event.lat); yield WeatherLoadSuccess(weather: weather, hourlyWeather: hourlyWeather); } catch (_) { yield WeatherLoadFailure(); } } Stream<WeatherState> _newWeatherCurrentPositionRequested() async* { LocationPermission permission = await checkPermission(); if (permission == LocationPermission.whileInUse || permission == LocationPermission.always) { Position lastKnownPosition = await getLastKnownPosition(); if(lastKnownPosition != null) { add(WeatherRequested( lat: lastKnownPosition.latitude.toString(), lon: lastKnownPosition.longitude.toString())); } else { Position position = await getCurrentPosition(desiredAccuracy: LocationAccuracy.high); add(WeatherRequested( lat: position.latitude.toString(), lon: position.longitude.toString())); } } else { await requestPermission(); add(WeatherCurrentPositionRequested()); } } }

Изменим главный экран чтобы добавить кнопку запроса локации:

class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => WeatherBloc(), child: BlocBuilder<WeatherBloc, WeatherState>( builder: (context, state) { if (state is WeatherLoadSuccess) { return Scaffold( appBar: AppBar( elevation: 0, backgroundColor: Color.fromRGBO(0, 0, 0, 0), actions: [ IconButton( icon: Icon(Icons.my_location), onPressed: () { BlocProvider.of<WeatherBloc>(context).add(WeatherCurrentPositionRequested()); }, ), IconButton( icon: Icon(Icons.search), onPressed: () { showSearch( context: context, delegate: MySearchDelegate((query) { BlocProvider.of<WeatherBloc>(context).add(WeatherRequested(city: query)); })); }, ) ], ), body: Padding( padding: EdgeInsets.only(top: 64), child: MainScreenWrapper( weather: state.weather, hourlyWeather: state.hourlyWeather), ), ); }

И проверяем что получилось:

Android
iOS

Спасибо всем кто дочитал до конца. Буду рад ответить на вопросы и готов выслушать любую критику.

4949
16 комментариев

Плюсанул за Flutter сразу)

12
Ответить

Комментарий недоступен

6
Ответить

Код без коментов это круто!

3
Ответить

Интересно, на vc.ru этого не хватает, но почему без какого-то конвейерного скрипта чтобы собрать на github ci или Jenkins? Интересно было бы запустить этот проект в github codespaces и в облаке потестить в процессе собранное приложение на каком-то стенде например с Selenoid Android :)

2
Ответить

Ну в случае флаттера лучше всё ж использовать https://codemagic.io/

1
Ответить

Задумка хорошя, спасибо! Стоило бы добавить во все приведенные куски кода include, которые им нужны (причем в некоторых кусках они есть, что сбивает). Не каждый новичок сообразит и полезет в полную версию кода на гитхабе, да и не удобно это.

2
Ответить

Спасибо, учту на будущее!

1
Ответить