Соединяем Flutter, Firebase и ESP32 в единую систему

от автора

Всем привет! Это Мурат Насиров, Flutter-разработчик в Friflex. Мы разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter. 

Сегодня расскажу, как использовать микроконтроллер ESP32 в связке с Flutter-приложением. В качестве примера покажу, как снимать показания температуры и влажности с помощью датчика DHT11 и передавать эти данные через Firebase Realtime Database в приложение.

Взаимодействие систем и общий план

Идея приложения заключается в следующем — создается Flutter-проект, к нему подключается Firebase, а также необходимые библиотеки для управления состоянием. Затем происходит настройка в сервисе Firebase базы данных Realtime Database, которая в реальном времени считывает показания с датчика и через микроконтроллер отправляет их в БД, где затем из этой базы данные считывает само приложение. Помимо прочего, будет реализована функция авторизации, которая позволит только созданным в Firebase аккаунтам управлять и мониторировать данными в БД.

Так как взаимодействие происходит между тремя системами, представлю вид того, как это будет выглядеть.

A — приложение, B — микроконтроллер ESP32

A — приложение, B — микроконтроллер ESP32

В целом, это все что надо знать о взаимодействии систем. Приступим к созданию проекта в Firebase.

Создаем проект в Firebase

Начинаем с регистрации проекта в Firebase Console. Следуем инструкции с картинками. Проходим авторизацию через Google-аккаунт и нажимаем Add Project.

Задаем имя проекта.

Отключаем сервис Google Analytics. В этом проекте он нам не нужен.

Готово. Теперь нужно подключить Firebase к Flutter-приложению. Сделаем это через Firebase CLI. Выбираем иконку Flutter.

Следуем инструкции. Перед этим не забудьте создать Flutter-проект.

После подключения Firebase на главной должны появиться проекты для iOS и Android, если вы выбрали их при установке Firebase.

Добавляем авторизацию через электронную почту и пароль.

Выбираем только Email/Password.

Добавляем двух пользователей: user@u.com — это пользователь, который будет проходить авторизацию в приложении. И device@d.com — это микроконтроллер ESP32. 

Учетные записи решают вопрос с безопасностью. Мы позволяем записывать данные в Realtime Database только пользователям с особыми правами. 

Обязательно сохраните учетные данные, они еще пригодятся.

Изучаем схему подключения микроконтроллера

Рассмотрим схему подключения.

Подключение DHT11 к ESP32

Подключение DHT11 к ESP32

Все достаточно просто. Схема состоит из микроконтроллера ESP32, а также датчика температуры и влажности DHT11. Пин подключения сигнального провода можно выбрать любой из доступных вам для вашей модели микроконтроллера (они бывают с разным количеством выходов, то есть, пинов).

Описание пинов

Описание пинов

У меня микроконтроллер выглядит вот так.

Микроконтроллер на макетной плате

Микроконтроллер на макетной плате

Пишем код для микроконтроллера

Теперь переходим к коду для микроконтроллера. Написать его можно в Arduino IDE или с помощью плагина PlatformIO для VS Code. Второй способ интереснее, поэтому я воспользуюсь им.

Заходим на сайт VS Code, скачиваем редактор кода для своей операционной системы. В разделе Extensions находим и устанавливаем Platformio IDE.

Во время установки оказалось, что у меня нет Python-интерпретатора. На моем компьютере установлена операционная система Ubuntu. Поэтому открываю консоль и устанавливаю последнюю версию:

sudo apt install python3-venv

В появившемся окне в VS Code выбираем Try again.

Теперь можно создавать проект. Выбираем удобную папку для проекта. В PlatformIO нажимаем Create New Project – New Project.

Задаем имя и в качестве Board выбираем Espressif ESP32 Dev Module.

В готовый проект устанавливаем библиотеки. Для этого переходим в раздел Libraries.

По очереди находим и устанавливаем DHT sensor library, Adafruit Unified Sensor и Firebase ESP32 Client.

В файле platformio.ini добавим пару строк скорости чтения и записи данных с платы:

upload_speed = 921600 monitor_speed = 115200

Пора кодить. Напишем скрипты для работы с Wi-Fi и датчиком. Создадим в папке lib папки: firebase, sensors и wifi.

Начнем с wifi, создадим в этой папке два файла DeviceWiFi.cpp и DeviceWiFi.h, где первый это файл с исполняемым кодом, а второй заголовочный файл. Такое разбиение на модули позволит затем удобно импортировать код в main.cpp.

DeviceWiFi.h

#include <WiFi.h>  /*! Подключение к Wi-Fi \param[in] ssid Имя точки доступа (Wi-Fi) \param[in] pass Пароль точки доступа (Wi-Fi) */ void connectToWiFi(const char *ssid, const char *pass);  /*! Переподключение к Wi-Fi, если соединение разорвано \param[in] ssid Имя точки доступа (Wi-Fi) \param[in] pass Пароль точки доступа (Wi-Fi) */ void loopReconnectToWiFi(const char *ssid, const char *pass);

DeviceWiFi.cpp

#include "DeviceWiFi.h" void connectToWiFi(const char *ssid, const char *pass) {   Serial.println("Устанавливается соединение с ");   Serial.print(ssid);   // Регистрация подключения на основе введенных данных   WiFi.begin(ssid, pass);   // Проверка подключения   while (WiFi.status() != WL_CONNECTED)   {     delay(1000);     Serial.print(".");   }   // Установка соединения, если все успешно   Serial.println("");   Serial.println("Соединение установлено!");    Serial.print("IP-адрес: ");   Serial.println(WiFi.localIP()); }  void loopReconnectToWiFi(const char *ssid, const char *pass) {   // Если соединение разорвано происходит переподключение со всеми настройками   while (WiFi.status() != WL_CONNECTED)   {     connectToWiFi(ssid, pass);   } }

Теперь напишем модуль с датчиком. Нам необходимо списать показания с DHT11 и обрабатывать их в ESP32. В папке sensors создаем файлы DeviceSensors.cpp и DeviceSensors.h

DeviceSensors.h

#include "DeviceFirebase.h"  /* Отправляет данные с датчиков в DeviceFirebase#loopSendDataToRTDB */ void loopSendSensorsData(); 

DeviceSensors.cpp

#include "DeviceSensors.h" #include <DHT.h>  /* Номер пина датчика температуры и влажности */ uint8_t DHTPin = 27; #define DHTTYPE DHT11 DHT dht(DHTPin, DHTTYPE);  void loopSendSensorsData() {   int temp = dht.readTemperature();   int hum = dht.readHumidity();   loopSendDataToRTDB(temp, hum); } 

Теперь все собранные показания нужно отправить по Wi-Fi в Firebase. Создадим модуль, который будет отвечать за аутентификацию по уже ранее созданному пользователю в базе данных. В папке firebase создаем файлы DeviceFirebase.cpp и DeviceFirebase.h

DeviceFirebase.h

#include <WString.h> #include <FirebaseESP32.h>  /*! Инициализация сервиса Firebase \param[in] deviceApiKey Уникальный ключ устройства (достаточно Web API Key) \param[in] deviceDatabaseUrl Ссылка на Firebase Realtime Database для записи данных \param[in] deviceEmail Email для авторизации устройства \param[in] devicePass Пароль */ void initFirebaseService(String deviceApiKey, String deviceDatabaseUrl, String deviceEmail, String devicePass);  /*! Отправка данных с сформированными путями в Realtime Database \param[in] temp Температура \param[in] hum Влажность */ void loopSendDataToRTDB(int temp, int hum);

DeviceFirebase.cpp

#include "DeviceFirebase.h" #include <addons/TokenHelper.h> #include <addons/RTDBHelper.h> #include <time.h>  FirebaseData fireData; FirebaseConfig fireConfig; FirebaseAuth fireAuth;  unsigned long epochTime;   // Получаем текущее время с сервера ntp unsigned long getTime() {  time_t now;  struct tm timeinfo;  if (!getLocalTime(&timeinfo)) {    return(0);  }  time(&now);   return now; }  void initFirebaseService(String deviceApiKey, String deviceDatabaseUrl, String deviceEmail, String devicePass) {  Serial.printf("Firebase Client v%s\n\n", FIREBASE_CLIENT_VERSION);   // Определение уникального ключа устройства и ссылки на Firebase Realtime Database  fireConfig.api_key = deviceApiKey;  fireConfig.database_url = deviceDatabaseUrl;   // Определение учетных данных для микроконтроллера  fireAuth.user.email = deviceEmail;  fireAuth.user.password = devicePass;   // Получение уникального токена микроконтроллера  fireConfig.token_status_callback = tokenStatusCallback;   // Регистрация работы сервиса  Firebase.begin(&fireConfig, &fireAuth);   // Получение UID и его отображение  Serial.println("Получение UID микроконтроллера");  while ((fireAuth.token.uid) == "")  {    Serial.print('.');    delay(1000);  }   Serial.print("Успешно подключено к Firebase!"); }  void loopSendDataToRTDB(int temp, int hum) {  String basePath = "/devices";  String tempPath = basePath + "/temperature";  String humpPath = basePath + "/humidity";  String timePath = basePath + "/timestamp";   epochTime = getTime();   if (Firebase.ready())  {    Firebase.setInt(fireData, tempPath, temp);    Firebase.setInt(fireData, humpPath, hum);    Firebase.setInt(fireData, timePath, epochTime);     // Задержка в 1 минуту    delay(60000);  } }

Для авторизации нам необходим Web API Key. Найти его можно с помощью кнопки справа от Project Overview — Project Settings — General. Этот API Key мы указываем в main.cpp вместе с логином и паролем устройства.

Открываем src/main.cpp и пишем:

main.cpp

#include "DeviceWiFi.h" #include "DeviceFirebase.h" #include "DeviceSensors.h"  // Подключение к WiFi const char *wifiSsid = "wifi_name"; const char *wifiPass = "wifi_pass";  // Сервис для получения текущего времени в секундах const char *ntpServer = "pool.ntp.org";  // Подключение к Firebase /* Уникальный ключ микроконтроллера, определяемый в Google Cloud */ String deviceApiKey = "device_api_key";  /* Адрес Firebase Realtime Databse */ String deviceDatabaseUrl = "device_database_url";  /* Электронная почта микроконтроллера */ String deviceEmail = "device_email";  /* Пароль микроконтроллера */ String devicePass = "device_pass";  void setup() {   Serial.begin(115200);   delay(10);    connectToWiFi(wifiSsid, wifiPass);      configTime(0, 0, ntpServer);    initFirebaseService(deviceApiKey, deviceDatabaseUrl, deviceEmail, devicePass); }  void loop() {   loopReconnectToWiFi(wifiSsid, wifiPass);   loopSendSensorsData(); } 

Помните, я говорил, что нам понадобятся электронная почта и пароль от учетной записи, которую вы создали в Firebase? Момент настал. Указываем эти данные в соответствующих полях. В качестве deviceApiKey указываем Web API Key, про который я также упоминал выше. 

Остается разобраться с deviceDatabaseUrl. Переходим в Firebase Console — Build — Realtime Database — Create Database. Выбираем сервер United States, затем Start in test mode. В этом режиме база данных (БД) будет существовать 30 дней. При необходимости можно увеличить срок.

Получаем БД, ура! Пока что данных в ней нет, но это легко исправить. Подключаем микроконтроллер ESP32 к компьютеру, после чего переходим в PlatformIO и нажимаем build

Чтобы после успешного билда загрузить наши скрипты, выбираем Upload and Monitor. Возвращаемся в Firebase Console — Build — Realtime Database и любуемся результатом.

Решаем возможные проблемы

Если ваш ESP32 не обнаруживается, возможно, нужно установить драйверы CH34x и CP210x. Проверьте работу USB-кабеля, вероятно, его необходимо заменить.

Чтобы проверить, опознается ли микроконтроллер, нужно подключить его к компьютеру и открыть раздел PlatformIO — Devices.

Также можно пройтись по инструкции на сайте Espressif.

Создаем Flutter-проект и настраиваем взаимодействие

Нам осталось написать приложение, которое будет отображать данные с микроконтроллера. Во время настройки Firebase CLI я говорил, что нам понадобится Flutter-проект. В нем вы увидите сгенерированные файлы.

Лишь lib/firebase_options.dart нужно добавить в /.gitignore, а android/app/google-services.json и ios/firebase_app_id_file.json можно удалить. Либо их можно тоже добавить в /.gitignore, если нужно.

Я постараюсь максимально кратко описать, какие решения использовал в приложении, чтобы отобразить значения с микроконтроллера. Остальной код вы сможете найти в репозитории в конце статьи.

Начнем с пакетов. Нам будет достаточно использовать менеджер состояния, библиотеки Firebase для авторизации и БД, а также библиотеку для преобразования текущего времени с микроконтроллера. 

pubspec.yaml

dependencies: intl: 0.19.0  #---NETWORK DATA---# firebase_core: 2.25.3 firebase_auth: 4.17.3 firebase_database: 10.4.4  #---STATE MANAGEMENT---# flutter_riverpod: 2.4.10

Инициализируем Firebase и Riverpod

Future<void> main() async {   WidgetsFlutterBinding.ensureInitialized();   await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);   runApp(const ProviderScope(child: MyApp())); }

Мы использовали Firebase CLI, поэтому в аргументе options обязательно указываем currentPlatform для платформ, которые мы выбрали при установке Firebase. 

В моем случае в lib/firebase_options.dart описывается попытка запуска на неподдерживаемой платформе. Для поддерживаемых платформ возвращаются сгенерированные ключи. Мы можем подкорректировать этот файл, потому что в моем случае запустить его можно только на Android, Linux и iOS.

class DefaultFirebaseOptions {   static FirebaseOptions get currentPlatform {    switch (defaultTargetPlatform) {     case TargetPlatform.android:       return android;     case TargetPlatform.iOS:       return ios;     case TargetPlatform.linux:       throw UnsupportedError(         'DefaultFirebaseOptions have not been configured for linux - '         'you can reconfigure this by running the FlutterFire CLI again.',       );     default:       throw UnsupportedError(         'DefaultFirebaseOptions are not supported for this platform.',       );     }   }    static const FirebaseOptions android = FirebaseOptions(     apiKey: 'apiKey',     appId: 'appId',     messagingSenderId: 'messagingSenderId',     projectId: 'projectId',     storageBucket: 'storageBucket',   );    static const FirebaseOptions ios = FirebaseOptions(     apiKey: 'apiKey',     appId: 'appId',     messagingSenderId: 'messagingSenderId',     projectId: 'projectId',     storageBucket: 'storageBucket',     iosBundleId: 'iosBundleId',   ); } 

Придадим нашему проекту следующую структуру.

Начнём с авторизации. Для этого создадим сервис, который будет выполнять функцию входа и выхода в приложение. Вход можно отследить при помощи значения id в модели User. Установим проверку, которая будет переключать на экран устройства при успешном входе. Либо на экран авторизации, если войти не удалось.

lib/auth/data/services/auth_service.dart

class AuthService {   /// Если аутентификация, либо авторизация прошла успешно, значение этого   /// геттера становится заполненная модель [User]   User? get user => FirebaseAuth.instance.currentUser;    String? get userId => user?.uid;    String? get userEmail => user?.email;    bool get isAlreadyLoggedIn => userId != null;    Future<UserCredential> login({     required String email,     required String password,   }) async {     return FirebaseAuth.instance.signInWithEmailAndPassword(       email: email,       password: password,     );   }    Future<void> logout() async => FirebaseAuth.instance.signOut(); }

Для управления состоянием нужна логика. Она будет отображать в UI нужные виджеты, в зависимости от состояния. Это простой запрос в сеть, поэтому у нас четыре состояния: инициализация, загрузка, ошибка, успех. В арсенале у Riverpod есть StateNotifier. Используя его вместе с AsyncValue (тоже из этого пакета), получаем следующую картину:

lib/auth/domain/auth_riverpod.dart

class AuthStateNotifier extends StateNotifier<AsyncValue<User?>> {   AuthStateNotifier({required AuthService service}) : _service = service,   // В super объявляется изначальное состояние (initial). Так как данных   // нет, ставим null   super(const AsyncData(null)) {     // Если пользователь уже был авторизован, просто меняем состояние на     // успешное     if (service.isAlreadyLoggedIn) state = AsyncData(service.user);   }    final AuthService _service;    Future<void> logout() async {     if (state.isLoading) return;      state = const AsyncLoading();     state = await AsyncValue.guard(() async {       await _service.logout();        // После выхода делаем User равным null, так как от id внутри этой модели       // определяется состояние авторизованности в приложении       return null;     });   }    Future<void> login({required String email, required String password}) async {     if (state.isLoading) return;     state = const AsyncLoading();      // AsyncValue.guard используется для удобства. По факту получается     // state = AsyncData(response.user) в случае успеха, либо     // state = AsyncError(error, stackTrace) в случае ошибки     state = await AsyncValue.guard(() async {       final response = await _service.login(         email: email,         password: password,       );       return response.user;     });   } }

Теперь создаем несколько провайдеров для манипуляций состояниями в UI-слое. Нам нужны провайдеры для:

  • текстовых полей логина и пароля;

  • определения статуса авторизации в приложении;

  • модели пользователя (User).

Создадим lib/auth/domain/auth_scope_providers.dart и добавим несколько провайдеров.

final loginAuthProvider = Provider.autoDispose<TextEditingController>((ref) {   final cotroller = TextEditingController();    // onDispose срабатывает в местах, где использовался   // ref.watch(loginAuthProvider). После выхода из экрана этот текстовый   // контроллер закрывается   ref.onDispose(() => controller.dispose());   return controller; });  final passAuthProvider = Provider.autoDispose<TextEditingController>((ref) {   final controller = TextEditingController();   ref.onDispose(() => controller.dispose());   return controller; });  final userProvider = Provider<User?>(   (ref) => ref.watch(authStateProvider).value, );  /// Провайдер текущего состояния авторизованности final authStateProvider = StateNotifierProvider<AuthStateNotifier, AsyncValue<User?>>(   (_) => AuthStateNotifier(service: AuthService()), ); 

После определения всех провайдеров можно приступать к вёрстке экрана. В экране будет отображаться простой стилизованный вид ввода учётных данных.

Я не буду описывать весь код UI, а лишь покажу интересные на мой взгляд моменты. Создадим lib/auth/presentation/screens/auth_screen.dart. В Riverpod есть возможность следить за определенным провайдером прямо в build методе, поэтому объявим наши TextEditingController для текстовых полей.

// Получаем значение TextEditingController, которое используем для    // текстового поля. ref.watch будет перестраивать виджет каждый раз, когда    // его значение меняется, например, при вызове у этого    // TextEditingController метода dispose    final loginController = ref.watch(loginAuthProvider);    final passController = ref.watch(passAuthProvider);    _listenAuthState(context, ref); 

И установим для каждого текстового поля.

TextField(  controller: loginController,  decoration: const InputDecoration(hintText: 'Email'),  textInputAction: TextInputAction.next,  keyboardType: TextInputType.emailAddress,  onEditingComplete: () => FocusScope.of(context).nextFocus(), )

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

ElevatedButton(  onPressed: () {    _completeInput(      context,      ref,      email: loginController.text.trim(),      pass: passController.text.trim(),    );  }, ... )

Эта кнопка выполняет функцию входа.

/// Метод завершения ввода логина и пароля void _completeInput(  BuildContext context,  WidgetRef ref, {  required String email,  required String pass, }) {  // Если у пользователя открыта клавиатура - скрываем ее  final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 1;  if (isKeyboardVisible) FocusScope.of(context).unfocus();   // Вызываем функцию для входа  ref.read(authStateProvider.notifier).login(email: email, password: pass); } 

Выше я упоминал функцию _listenAuthState. Опишем ее.

/// Отслеживает состояние авторизованности пользователя void _listenAuthState(BuildContext context, WidgetRef ref) {  ref.listen<AsyncValue<User?>>(authStateProvider, (previous, next) async {    ...     // В случае успешной авторизации переходим на экран устройств    if (next.hasValue && context.mounted) {      Navigator.of(context).pushReplacement(        MaterialPageRoute(builder: (_) => const DevicesScreen()),      );    }  }); }

Со входом мы разобрались. Теперь приступаем к экрану с показаниями датчика DHT11. Начнем с модели. В видео выше было показано, что данные хранятся в Firebase Realtime Database. Это просто Map с набором ключ или значение. Поэтому нам нужно сделать простой маппинг этих данных.

lib/devices/domain/model/devices_data.dart

@immutable class DevicesData {  const DevicesData({    required this.humidity,    required this.temperature,    required this.readingDateTime,  });   /// Влажность  final int? humidity;   /// Температура  final int? temperature;   /// Время считывания  final DateTime? readingDateTime;   @override  bool operator ==(Object other) =>      identical(this, other) ||      other is DevicesData &&          runtimeType == other.runtimeType &&          humidity == other.humidity &&          temperature == other.temperature &&          readingDateTime == other.readingDateTime;   @override  int get hashCode =>      humidity.hashCode ^ temperature.hashCode ^ readingDateTime.hashCode;   static DevicesData fromJson(Map json) {    // Так как отсчет времени происходит с начала эпохи (01.01.1970) в секундах, здесь происходит преобразование этого времени в понятный вид    final timestamp = json['timestamp'];    final readingDateTime = timestamp != null        ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)        : null;     return DevicesData(      temperature: json['temperature'],      humidity: json['humidity'],      readingDateTime: readingDateTime,    );  } } 

В логике мы сразу после получения данных смаппим их в эту модели.

lib/devices/domain/devices_riverpod.dart

class DevicesStateNotifier extends StateNotifier<AsyncValue<DevicesData?>> {  DevicesStateNotifier({required DatabaseReference database})      : _database = database,        super(const AsyncData(null));   final DatabaseReference _database;   late StreamSubscription<DatabaseEvent> _subscription;   Future<void> getDevicesData() async {    state = const AsyncLoading();    // Обновление данных происходит в реальном времени    _subscription = _database.onValue.listen((event) {      final data = event.snapshot.child('devices').value as Map?;      if (data == null) return;       state = AsyncData(DevicesData.fromJson(data));    }, onError: (e, s) => state = AsyncError(e, s));  }   @override  void dispose() {    _subscription.cancel();    super.dispose();  } } 

Порядок, осталось сделать провайдер.

lib/devices/domain/devices_scope_providers.dart

final devicesStateProvider = StateNotifierProvider.autoDispose<    DevicesStateNotifier, AsyncValue<DevicesData?>>((_) {  return DevicesStateNotifier(database: FirebaseDatabase.instance.ref())    // При входе на экран, где используется этот провайдер сразу же начнется    // запрос в сеть посредством вызова getDevicesData    ..getDevicesData(); });

Мы близки к завершению, осталось лишь написать простой UI для отображения показаний датчика. Создав lib/devices/presentation/screens/devices_screen.dart в функции build нужно реагировать на состояние, когда пользователь вышел из аккаунта.

final user = ref.watch(userProvider);    // authStateProvider провайдер вызовется по нажатию кнопки выхода. В теле    // этого слушателя стоит проверка    ref.listen<AsyncValue<User?>>(authStateProvider, (prev, next) {      final uid = next.value?.uid;       // Если пользователь вышел - заменяем текущий экран на экран авторизации      if (uid == null) {        Navigator.of(context).pushReplacement(          MaterialPageRoute(builder: (_) => const AuthScreen()),        );      }    }); 

Так как получение данных это асинхронная операция, в параметре body виджета Scaffold удобнее использовать функцию when, которую предоставляет Riverpod.

Scaffold(  ...  // Метод when в асинхронных операциях в Riverpod позволяет удобно  // распределять события загрузки, ошибки и успеха на нужные виджеты,  // которые описаны здесь  body: deviceData.when(    loading: () {...},    error: (error, stackTrace) {...},    data: (data) {...},  )  

В аргументе data находятся показания температуры и влажности, а также timestamp, которые меняются динамически, раз в 1 минуту. Достаточно просто отобразить эти данные в UI.

Осталось самое главное.

lib/main.dart

Future<void> main() async {  WidgetsFlutterBinding.ensureInitialized();  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);  runApp(const ProviderScope(child: MyApp())); }  class MyApp extends ConsumerWidget {  const MyApp({super.key});   @override  Widget build(BuildContext context, WidgetRef ref) {    return MaterialApp(      title: 'Flutter Arduino',      debugShowCheckedModeBanner: false,      home: Consumer(        builder: (context, ref, child) {          final uid = ref.watch(userProvider)?.uid;          if (uid != null) return const DevicesScreen();           return const AuthScreen();        },      ),    );  } } 

Все готово! Можно смотреть на красоту 🙂

Надеюсь, вам было интересно. Если появились вопросы, пишите в комментариях. Всем пока!

Исходники: код микроконтроллера, код Flutter-проекта.


ссылка на оригинал статьи https://habr.com/ru/articles/795347/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *