IT-инфраструктура для бизнеса и творчества

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

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

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

Для тех кто не хочет читать много текста есть ссылка на репозиторий 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

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

{ "author_name": "Евгений Усов", "author_type": "self", "tags": ["selectel_\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f"], "comments": 16, "likes": 45, "favorites": 119, "is_advertisement": false, "subsite_label": "dev", "id": 155933, "is_wide": true, "is_ugc": true, "date": "Sun, 06 Sep 2020 11:14:10 +0300", "is_special": false }
(function () { let cdnUrl = `https://specialsf378ef5-a.akamaihd.net/SelectelBranding/images/` let previousArticleNumber = null let currentArticleNumber = 0 let platform = 'Desktop' let articles = [ { name: 'camera', url: `${cdnUrl}CameraCat`, text: 'умную камеру для\u00A0наблюдения за\u00A0котиками', link: 'https://vc.ru/selectel/306690', num: 3 }, { name: 'chill', url: `${cdnUrl}ChillCat`, text: 'трекер, который подскажет, когда пора отдохнуть', link: 'https://vc.ru/promo/288561-eye-tracker', num: 1 }, { name: 'cloud', url: `${cdnUrl}CloudCat`, text: 'котика: даёшь ему «пять», а\u00A0он делает бэкап в облако', link: 'https://vc.ru/dev/294799-maneki-neko', num: 2 } ] let buttonCycle = document.querySelector('.button--cycle') let buttonChoose = document.querySelector('.button--choose') let buttonMobile = document.querySelector('.button--mobile') let textField = document.querySelector('.selectel-footer-subtitle') let imageAgent = document.querySelector('.image--agent') let banner = document.querySelector('.selectel-footer') buttonCycle.addEventListener('click', cycleClick) buttonChoose.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) buttonMobile.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) let media = window.matchMedia("(max-width: 570px)") media.addEventListener('change', matchMedia) function matchMedia() { if (media.matches) { platform = 'Mobile' } else { platform = 'Desktop' } update() } matchMedia() function cycleClick(event) { sendEvent(`Promo ${articles[currentArticleNumber].num} Right`, 'Click') if (event) { event.preventDefault() event.stopPropagation() } window.open('https://vc.ru/tag/selectelDIY', '_blank') //cycle(event) } function cycle(event) { // incrementArticleNumber() textField.innerHTML = generatedText() imageAgent.src = articles[currentArticleNumber].url + platform + '.svg?3' imageAgent.setAttribute("class", "") imageAgent.classList.add('image--agent', articles[currentArticleNumber].name) banner.href = articles[currentArticleNumber].link } function update() { banner.href = articles[currentArticleNumber].link imageAgent.src = articles[currentArticleNumber].url + platform + '.svg' textField.innerHTML = generatedText() } function incrementArticleNumber() { previousArticleNumber = currentArticleNumber if (currentArticleNumber >= articles.length - 1) { currentArticleNumber = 0 } else { currentArticleNumber++ } } const sendEvent = (label, action = 'Click') => { const value = `SelectelDIY — loc: Footer — ${label} — ${action}`; if (window.dataLayer !== undefined) { window.dataLayer.push({ event: 'data_event', data_description: value, }); } }; function generatedText() { let defaultText if (platform === 'Desktop') { defaultText = `Мы тут собрали %text%. Хотите научим?` } else { defaultText = `Мы тут собрали %text%.` } return defaultText.replace('%text%', articles[currentArticleNumber].text) } function getRandom(min, max) { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } (function create() { currentArticleNumber = getRandom(0, articles.length - 1) cycle() let page = document.querySelector('.page--entry') if (page) { function insertAfter() { let parents = page.querySelectorAll('[data-id="7"]') let referenceNode = parents[0] referenceNode.parentNode.insertBefore(banner, referenceNode.nextSibling); loaded() } setTimeout(() => insertAfter(), 0) } }()) function loaded() { banner.classList.add('loaded') } loadImages([ `${cdnUrl}CameraCatDesktop.svg`, `${cdnUrl}ChillCatDesktop.svg`, `${cdnUrl}CloudCatDesktop.svg`, `${cdnUrl}CameraCatMobile.svg`, `${cdnUrl}ChillCatMobile.svg`, `${cdnUrl}CloudCatMobile.svg?3`, ]) function loadImages(urls) { return Promise.all(urls.map(function (url) { return new Promise(function (resolve) { var img = document.createElement('img'); img.onload = resolve; img.onerror = resolve; img.src = url; }); })); } }())
0
16 комментариев
Популярные
По порядку
Написать комментарий...

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

11

Плюсанул за Мурино)

6

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

3

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

2

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

1

Спасибо, Майк, потестирую!

1

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

2

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

1

Сколько времени было потрачено на такой проект?

0

Ну у меня ушёл весь вчерашний вечер

5

Это самый простой пример для того, чтобы показать как что-то кодить. Не удивлён что нашёлся похожий. Ну и конечно на оригинальность приложения я не претендовал. Цель статьи и приложения показать как можно написать быстро простое приложение с нормальной архитектурой. 

1

Спасибо, на самом деле приятный материал, изучаю flutter сам, и Вы внесли некие ясности в BLOC)

P.S.Не могли бы Вы выгрузить код в репозиторий на гитхаб? 

0

В начале стать есть ссылка 

0

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

Кому интересен Flutter - заходите и к нам: https://openflutter.ru/ Есть канал Youtube, видео будут пополняться.

0

сайт не работает

0
Читать все 16 комментариев
Cloud CDN: что это такое, как устроено и кому нужно. Разбираем на примере бургеров

Cloud CDN — это сеть быстрой доставки статического контента в формате услуги облачного провайдера. Объяснить, как работает технология, проще всего на примере — сравнить Cloud CDN с популярным продуктом, который выглядит плюс-минус одинаково вне зависимости от того, заказали вы его в Москве, Питере или Нью-Йорке. Знакомьтесь: классический бургер.…

ПСБ запустил личный кабинет для предпринимателей. Там можно следить онлайн за каждым своим терминалом

Сервис предоставляется бесплатно.

Как OTUS стал платформой для самореализации. История преподавателя

Наш преподаватель, специалист по Data Science, решил поделиться своей историей преподавания. Он рассказал, как пришел в эту сферу, с какими трудностями столкнулся на пути к преподаванию и что ему помогает. А еще поделился советами, как поддерживать внимание студентов и сделать занятия полезными и увлекательными.

Как Озон спустя неделю обещаний о доставке товара молча отменил мой заказ

Добрый день, мой первый пост о той ситуации, с которой наверное столкнулись многие.

Как не попасть в карьерную ловушку тимлида: личный опыт

Кажется, что тимлиду просто некуда расти: дальше надо либо идти в менеджмент, либо наоборот, становиться узконаправленным разработчиком. По просьбе «Лаборатории Касперского» Евгений Мацюк, который прошел в компании неординарный путь, рассказал о своих карьерных развилках во время и после тимлидства, а также поделился опытом горизонтального роста.

М.Видео обманул меня с предзаказом Apple Watch Series 7

Печали пост. Как только 8 октября открылся предзаказ на Apple Watch Series 7, поспешил на сайты apple.com, М.Видео и еще несколько маркетплейсов.

Правительство утвердило правила идентификации пользователей мессенджеров с марта 2022 года Статьи редакции

Сервисы должны будут запрашивать данные у операторов, а те — предоставлять их в течение 20 минут после регистрации пользователя.

В Петербурге объявили локдаун с 30 октября по 7 ноября: работать будут продуктовые магазины и аптеки Статьи редакции

Кафе и рестораны смогут работать навынос и на доставку, будут открыты парикмахерские, театры и музеи.

Исследование: сотрудники хотели бы иметь комнату отдыха, бесплатный сок, а работодатели уже готовы покупать ЗОЖ-снеки

Онлайн-сервис доставки продуктов и товаров СберМаркет и исследовательское агентство Research Me спросили сотрудников, как они хотели бы питаться в офисе и что в нем видеть. В опросе приняли участие более 1500 работающих людей по всей России. Сервис также спросил работодателей – В2В-клиентов СберМаркета: что они покупают в офис, что точно никогда…

Я устал жить на автомате и сделал бота в Telegram, который напоминает сколько мне осталось жить

Теперь бот присылает каждую неделю новую таблицу жизни, где видно сколько мне осталось до 90 лет. Красный квадрат – 1 прожитая неделя.

Пример календаря жизни. @life_table_bot
null