Введение: От скрипта к полноценному приложению
Каждому Python-разработчику знакома ситуация: готов полезный скрипт, но он заперт в консоли. Как поделиться им с коллегой, далеким от терминала? Раньше это означало погружаться в дебри Tkinter, изучать монструозный PyQt или тащить тяжеловесный Electron ради пары кнопок.
К счастью, эти времена прошли. Знакомьтесь, Flet — фреймворк, который позволяет создавать современные кросс-платформенные приложения, используя только Python. В его основе лежит Flutter, но вам не придется писать ни строчки на Dart. Вы просто описываете интерфейс Python-объектами, а Flet берет на себя всю магию по его отрисовке.
В этой статье мы не будем разбирать теорию. Мы с нуля напишем полезную утилиту — мини-редактор изображений, который умеет открывать файлы, применять базовые фильтры (Ч/Б, размытие, поворот) и сохранять результат.
Вот что у нас получится в итоге:

Давайте посмотрим, как превратить простой скрипт в полноценное десктопное приложение, которое не стыдно показать другим.
Часть 1: Подготовка и создание каркаса приложения
Прежде чем погрузиться в код, давайте выполним несколько обязательных шагов, которые обеспечат чистоту и предсказуемость нашего проекта.
Шаг 0: Виртуальное окружение — цифровая гигиена
Хороший тон в Python-разработке — начинать любой проект с создания виртуального окружения. Это изолированное пространство, которое позволяет устанавливать зависимости для конкретного проекта, не засоряя глобальную установку Python и избегая конфликтов версий библиотек.
Откройте терминал в папке вашего проекта и выполните следующие команды:
-
Создаем окружение (назовем его
venv):python -m venv venv -
Активируем его:
-
Windows (Command Prompt / PowerShell):
venv\Scripts\activate -
macOS / Linux:
source venv/bin/activate
-
После активации вы увидите (venv) в начале строки вашего терминала. Это значит, что мы готовы к работе.
Шаг 1: Установка зависимостей
Нашему приложению понадобятся всего две библиотеки: flet для создания интерфейса и pillow для всех манипуляций с изображениями. Установим их одной командой:
pip install flet pillow
Вот и вся подготовка. Просто, не так ли?
Шаг 2: Базовая структура Flet-приложения
Теперь создадим наш главный Python-файл, например, main.py. Любое Flet-приложение имеет простую и понятную структуру:
Импортируем библиотеку, обычно как
ft.Создаем функцию
main, которая принимает один аргумент —page. Этот объектpageявляется нашим главным окном или холстом, куда мы будем добавлять все элементы.Запускаем приложение с помощью
ft.app().
Давайте напишем минимальный код, чтобы убедиться, что все работает:
# main.py
import flet as ft
def main(page: ft.Page):
# Настраиваем окно приложения
page.title = "Flet Image Editor"
page.window_width = 600
page.window_height = 800
page.vertical_alignment = ft.MainAxisAlignment.CENTER
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
# Добавляем на страницу простой текст
page.add(
ft.Text("Каркас нашего приложения готов!", size=20)
)
# Запускаем приложение, указывая нашу main-функцию как цель
ft.app(target=main)
Запустите этот файл (python main.py), и вы должны увидеть окно с заголовком и приветственным текстом. Отлично, мы на верном пути!
Шаг 3: Проектирование и создание каркаса интерфейса
Теперь самое интересное — спроектируем наш интерфейс. Мы хотим разместить элементы вертикально, один под другим. Для этого идеально подходит виджет ft.Column. Внутри него мы расположим горизонтальные ряды (ft.Row) с кнопками и основной виджет для отображения картинки (ft.Image).
Давайте заменим наш приветственный текст на реальные, но пока нефункциональные элементы управления.
# main.py
import flet as ft
def main(page: ft.Page):
page.title = "Flet Image Editor"
page.window_width = 600
page.window_height = 800
# Выравниваем все по центру
page.vertical_alignment = ft.MainAxisAlignment.CENTER
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
# --- Виджеты нашего приложения ---
# Кнопки для работы с файлами
open_button = ft.ElevatedButton("Открыть")
save_button = ft.ElevatedButton("Сохранить", disabled=True) # Кнопка неактивна, пока нет изображения
# Изображение, которое мы будем редактировать
# Изначально оно невидимо
image_view = ft.Image(
visible=False,
width=500,
height=500,
fit=ft.ImageFit.CONTAIN,
)
# Кнопки фильтров
filters_row = ft.Row(
controls=[
ft.ElevatedButton("Ч/Б"),
ft.ElevatedButton("Размытие"),
ft.ElevatedButton("Поворот"),
],
visible=False, # Панель фильтров тоже скрыта
alignment=ft.MainAxisAlignment.CENTER,
)
# --- Собираем интерфейс ---
# Добавляем все виджеты на страницу в одну колонку
page.add(
ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER),
image_view,
filters_row
)
ft.app(target=main)
Запустите код еще раз. Теперь у вас есть окно с кнопками "Открыть" и неактивной "Сохранить". Изображение и фильтры пока скрыты — мы покажем их, как только пользователь выберет файл.

Наш каркас готов! Мы создали структуру приложения и разместили все необходимые элементы.
Часть 2: Открываем и отображаем изображение
Чтобы приложение могло "общаться" с файловой системой компьютера — открывать стандартные диалоговые окна "Выбор файла" или "Сохранить как" — Flet предоставляет специальный виджет-помощник: ft.FilePicker.
Шаг 1: Добавляем FilePicker на страницу
В отличие от кнопок и полей, FilePicker — это невидимый компонент. Он живет в "слое оверлеев" (page.overlay) и ждет, пока его вызовут.
Давайте создадим экземпляр FilePicker и добавим его на нашу страницу. Также нам нужно сразу "подписать" его на событие on_result — это событие сработает, когда пользователь выберет файл и закроет диалоговое окно.
Добавьте этот код в функцию main, перед тем как мы собираем интерфейс.
# ... внутри def main(page: ft.Page):
# --- Диалоговое окно для выбора файла ---
def on_file_picker_result(e: ft.FilePickerResultEvent):
# Эта функция будет вызвана, когда пользователь выберет файл
# Пока оставим её пустой
print("Выбран файл:", e.files)
file_picker = ft.FilePicker(on_result=on_file_picker_result)
# Добавляем FilePicker в оверлей страницы, чтобы он был доступен
page.overlay.append(file_picker)
# --- Виджеты нашего приложения ---
# ... (остальной код виджетов)
Мы создали сам FilePicker и функцию on_file_picker_result, которая будет обрабатывать его результат.
Шаг 2: Оживляем кнопку "Открыть"
Теперь нам нужно связать нашу кнопку "Открыть" с FilePicker. Делается это очень просто: по нажатию на кнопку мы должны вызывать метод pick_files() у нашего file_picker. Этот метод и откроет системное диалоговое окно.
Мы можем добавить обработчик on_click прямо при создании кнопки.
# Заменим создание кнопки open_button
open_button = ft.ElevatedButton(
"Открыть",
on_click=lambda _: file_picker.pick_files(
allow_multiple=False, # Запрещаем выбор нескольких файлов
allowed_extensions=["jpg", "jpeg", "png", "bmp"], # Фильтруем типы файлов
dialog_title="Выберите изображение"
)
)
Мы использовали lambda-функцию для краткости. Теперь при нажатии на кнопку "Открыть" Flet покажет окно выбора файла, причем в нем будут видны только изображения указанных форматов.
Ша-г 3: Обрабатываем выбор пользователя и отображаем картинку
Самая важная часть. Когда пользователь выбе��ет файл, сработает событие on_result, и Flet вызовет нашу функцию on_file_picker_result. Внутри этой функции нам нужно:
Проверить, что пользователь действительно выбрал файл, а не закрыл окно.
Получить путь к выбранному файлу.
Установить этот путь как источник (
src) для нашего виджетаft.Image.Сделать видимыми само изображение и панель с фильтрами.
Активировать кнопку "Сохранить".
Вызвать
page.update(), чтобы все эти изменения отобразились на экране.
Давайте наполним нашу функцию on_file_picker_result логикой:
# Заполняем нашу функцию-обработчик
def on_file_picker_result(e: ft.FilePickerResultEvent):
# Если пользователь отменил выбор, e.files будет None
if e.files:
# Получаем путь к первому выбранному файлу
file_path = e.files[0].path
# Обновляем источник изображения
image_view.src = file_path
image_view.visible = True
# Делаем видимой панель фильтров и активной кнопку "Сохранить"
filters_row.visible = True
save_button.disabled = False
# Обязательно обновляем страницу, чтобы применить изменения
page.update()
Вот и все! Давайте посмотрим на полный код main.py после всех изменений.
# main.py
import flet as ft
def main(page: ft.Page):
page.title = "Flet Image Editor"
page.window_width = 600
page.window_height = 800
page.vertical_alignment = ft.MainAxisAlignment.CENTER
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
def on_file_picker_result(e: ft.FilePickerResultEvent):
if e.files:
file_path = e.files[0].path
image_view.src = file_path
image_view.visible = True
filters_row.visible = True
save_button.disabled = False
page.update()
file_picker = ft.FilePicker(on_result=on_file_picker_result)
page.overlay.append(file_picker)
open_button = ft.ElevatedButton(
"Открыть",
on_click=lambda _: file_picker.pick_files(
allow_multiple=False,
allowed_extensions=["jpg", "jpeg", "png", "bmp"],
dialog_title="Выберите изображение"
)
)
save_button = ft.ElevatedButton("Сохранить", disabled=True)
image_view = ft.Image(
visible=False,
width=500,
height=500,
fit=ft.ImageFit.CONTAIN,
)
filters_row = ft.Row(
controls=[
ft.ElevatedButton("Ч/Б"),
ft.ElevatedButton("Размытие"),
ft.ElevatedButton("Поворот"),
],
visible=False,
alignment=ft.MainAxisAlignment.CENTER,
)
page.add(
ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER),
image_view,
filters_row
)
ft.app(target=main)
Запустите скрипт. Теперь кнопка "Открыть" активна. Нажмите на нее, выберите любое изображение на вашем компьютере, и оно тут же появится в окне приложения вместе с панелью фильтров.

Наше приложение стало интерактивным!
Часть 3: "Швейцарский нож" в действии — применяем фильтры
Сейчас наши кнопки "Ч/Б", "Размытие" и "Поворот" — просто элементы интерфейса. Наша задача — связать их с реальными операциями по обработке изображений. Flet будет отвечать за нажатия кнопок, а всю тяжелую работу по пиксельным манипуляциям возьмет на себя Pillow.
Шаг 1: Состояние приложения — где хранить изображение?
Когда пользователь применяет фильтр, мы не можем просто изменить исходный файл на диске. Нам нужно работать с копией изображения в памяти. Давайте заведем переменную, в которой будем хранить текущее состояние изображения в виде объекта Pillow.
Добавьте в начало функции main импорты из Pillow и io (он понадобится нам для работы с данными в памяти), а также создайте переменную-хранилище current_image.
# main.py
import flet as ft
from PIL import Image, ImageFilter # Импортируем Image и ImageFilter
import io # Импортируем io
import base64 # Импортируем base64
def main(page: ft.Page):
# ... настройки страницы ...
# Переменная для хранения текущего изображения в формате Pillow
current_image: Image = None
# ... остальной код ...
Теперь, когда пользователь выбирает файл, нам нужно не только показать его, но и загрузить в нашу переменную current_image. Обновим функцию on_file_picker_result:
# Обновляем on_file_picker_result
def on_file_picker_result(e: ft.FilePickerResultEvent):
nonlocal current_image # Указываем, что будем изменять внешнюю переменную
if e.files:
file_path = e.files[0].path
# Загружаем изображение в Pillow и сохраняем его
current_image = Image.open(file_path)
image_view.src = file_path
image_view.visible = True
filters_row.visible = True
save_button.disabled = False
page.update()
Шаг 2: Ключевая магия — как показать измененное изображение
Вот тут и кроется главный технический момент. Когда мы применим фильтр, у нас будет измененный объект Image от Pillow в памяти. Но как его показать в виджете ft.Image? Ведь его свойство src ожидает путь к файлу.
Решение: свойство src_base64. Оно позволяет загрузить изображение напрямую из строки, закодированной в формате Base64. Наш план:
Взять измененный объект Pillow
Image."Сохранить" его не на диск, а в специальный буфер в оперативной памяти (
io.BytesIO).Получить из буфера байты изображения.
Закодировать эти байты в строку Base64.
Передать эту строку в
image_view.src_base64.
Давайте напишем для этого небольшую вспомогательную функцию, чтобы не дублировать код.
# Добавим эту функцию внутрь main
def update_image_view(img_obj: Image):
"""Конвертирует объект Pillow в base64 и обновляет виджет."""
buffer = io.BytesIO()
img_obj.save(buffer, format="PNG") # Сохраняем в буфер в формате PNG
base64_img = base64.b64encode(buffer.getvalue()).decode("utf-8")
image_view.src_base64 = base64_img
page.update()
Шаг 3: Реализуем фильтры
Теперь, когда у нас есть все необходимое, написать сами обработчики фильтров — одно удовольствие. Для каждой кнопки создадим свою функцию.
Начнем с черно-белого фильтра:
# Функция для Ч/Б фильтра
def apply_bw_filter(e):
nonlocal current_image
if current_image:
# Применяем фильтр
bw_image = current_image.convert("L")
# Обновляем текущее изображение
current_image = bw_image
# Обновляем виджет на странице
update_image_view(current_image)
Размытие (Blur):
# Функция для размытия
def apply_blur_filter(e):
nonlocal current_image
if current_image:
blurred_image = current_image.filter(ImageFilter.BLUR)
current_image = blurred_image
update_image_view(current_image)
Поворот:
# Функция для поворота
def apply_rotate_filter(e):
nonlocal current_image
if current_image:
# Поворачиваем на 90 градусов против часовой стрелки
rotated_image = current_image.rotate(90, expand=True)
current_image = rotated_image
update_image_view(current_image)
Осталось только привязать эти функции к on_click наших кнопок. Найдите место, где создается filters_row, и обновите его:
# Обновляем filters_row
filters_row = ft.Row(
controls=[
ft.ElevatedButton("Ч/Б", on_click=apply_bw_filter),
ft.ElevatedButton("Размытие", on_click=apply_blur_filter),
ft.ElevatedButton("Поворот", on_click=apply_rotate_filter),
],
visible=False,
alignment=ft.MainAxisAlignment.CENTER,
)
Готово! Запустите приложение, откройте изображение и попробуйте нажать на кнопки фильтров. Вы увидите, как картинка мгновенно меняется. Причем фильтры применяются последовательно: вы можете сделать изображение черно-белым, а затем повернуть его.
Часть 4: Сохранение результата и обратная связь
Сейчас кнопка "Сохранить" активна, но безжизненна. Мы это исправим. Вся логика у нас уже есть: измененное изображение хранится в переменной current_image. Нам лишь нужно спросить у пользователя, куда его сохранить.
Шаг 1: Настраиваем сохранение файла
Для сохранения мы снова воспользуемся FilePicker, но на этот раз вызовем у него метод save_file(). Чтобы не усложнять логику нашего единственного обработчика (on_file_picker_result), который сейчас заточен под открытие файлов, лучшей практикой будет создать отдельный FilePicker специально для сохранения.
Создадим новый
FilePickerи его функцию-обработчикon_save_file_result.Добавим его также в
page.overlay.Привяжем его вызов к кнопке "Сохранить".
# Добавляем этот код внутрь main, рядом с первым пикером
# --- Диалоговое окно для СОХРАНЕНИЯ файла ---
def on_save_file_result(e: ft.FilePickerResultEvent):
# Логика сохранения будет здесь
pass
save_file_picker = ft.FilePicker(on_result=on_save_file_result)
page.overlay.append(save_file_picker)
# ...
# Обновляем создание кнопки "Сохранить"
save_button = ft.ElevatedButton(
"Сохранить",
disabled=True,
on_click=lambda _: save_file_picker.save_file(
dialog_title="Сохранить как...",
file_name="edited_image.png", # Имя файла по умолчанию
allowed_extensions=["png", "jpg", "bmp"]
)
)
Шаг 2: Реализуем логику сохранения и добавляем обратную связь
Теперь наполним нашу новую функцию on_save_file_result. Она должна:
Проверить, что пользователь указал путь для сохранения.
Вызвать метод
save()у нашего объектаcurrent_imageиз Pillow.Показать пользователю уведомление, что все прошло успешно.
Для уведомлений в Flet есть замечательный виджет — ft.SnackBar. Он появляется внизу экрана и через несколько секунд исчезает.
Давайте допишем финальный код.
# Добавляем создание SnackBar в начало функции main
def main(page: ft.Page):
# ...
# Создаем SnackBar для уведомлений
page.snack_bar = ft.SnackBar(content=ft.Text("Сообщение!"))
# ...
# Теперь заполняем функцию on_save_file_result
def on_save_file_result(e: ft.FilePickerResultEvent):
# Если пользователь выбрал путь для сохранения
if e.path:
try:
current_image.save(e.path)
# Показываем уведомление об успехе
page.snack_bar.content = ft.Text(f"Изображение успешно сохранено в {e.path}")
page.snack_bar.open = True
page.update()
except Exception as ex:
# Показываем уведомление об ошибке
page.snack_bar.content = ft.Text(f"Ошибка при сохранении: {ex}")
page.snack_bar.open = True
page.update()
Мы обернули сохранение в try...except, чтобы поймать возможные ошибки (например, нет прав на запись) и вежливо сообщить о них пользователю.
Поздравляю! Наше приложение полностью функционально. Мы прошли весь путь от пустого файла до полноценного десктопного редактора изображений. Мы научились работать с файловой системой, обрабатывать события, интегрировать мощные библиотеки и предоставлять пользователю качественную обратную связь.
▶️ Полный код main.py
import flet as ft
from PIL import Image, ImageFilter
import io
import base64
def main(page: ft.Page):
# --- НАСТРОЙКИ ОКНА ---
page.title = "Flet Image Editor"
page.window_width = 600
page.window_height = 800
page.vertical_alignment = ft.MainAxisAlignment.CENTER
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
page.snack_bar = ft.SnackBar(content=ft.Text("Сообщение!"))
# --- ПЕРЕМЕННЫЕ СОСТОЯНИЯ ---
current_image: Image = None
# --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
def update_image_view(img_obj: Image):
"""Конвертирует объект Pillow в base64 и обновляет виджет."""
buffer = io.BytesIO()
img_obj.save(buffer, format="PNG")
base64_img = base64.b64encode(buffer.getvalue()).decode("utf-8")
image_view.src_base64 = base64_img
page.update()
# --- ОБРАБОТЧИКИ СОБЫТИЙ ---
def on_file_picker_result(e: ft.FilePickerResultEvent):
nonlocal current_image
if e.files:
file_path = e.files[0].path
current_image = Image.open(file_path)
image_view.src = file_path
image_view.visible = True
filters_row.visible = True
save_button.disabled = False
page.update()
def on_save_file_result(e: ft.FilePickerResultEvent):
if e.path and current_image:
try:
current_image.save(e.path)
page.snack_bar.content = ft.Text(f"Изображение успешно сохранено в {e.path}")
page.snack_bar.open = True
page.update()
except Exception as ex:
page.snack_bar.content = ft.Text(f"Ошибка при сохранении: {ex}")
page.snack_bar.open = True
page.update()
def apply_bw_filter(e):
nonlocal current_image
if current_image:
current_image = current_image.convert("L")
update_image_view(current_image)
def apply_blur_filter(e):
nonlocal current_image
if current_image:
current_image = current_image.filter(ImageFilter.BLUR)
update_image_view(current_image)
def apply_rotate_filter(e):
nonlocal current_image
if current_image:
current_image = current_image.rotate(90, expand=True)
update_image_view(current_image)
# --- FILE PICKERS ---
open_file_picker = ft.FilePicker(on_result=on_file_picker_result)
save_file_picker = ft.FilePicker(on_result=on_save_file_result)
page.overlay.extend([open_file_picker, save_file_picker])
# --- ВИДЖЕТЫ ИНТЕРФЕЙСА ---
open_button = ft.ElevatedButton(
"Открыть",
on_click=lambda _: open_file_picker.pick_files(
allow_multiple=False,
allowed_extensions=["jpg", "jpeg", "png", "bmp"],
dialog_title="Выберите изображение"
)
)
save_button = ft.ElevatedButton(
"Сохранить",
disabled=True,
on_click=lambda _: save_file_picker.save_file(
dialog_title="Сохранить как...",
file_name="edited_image.png",
allowed_extensions=["png", "jpg", "bmp"]
)
)
image_view = ft.Image(
visible=False,
width=500,
height=500,
fit=ft.ImageFit.CONTAIN,
)
filters_row = ft.Row(
controls=[
ft.ElevatedButton("Ч/Б", on_click=apply_bw_filter),
ft.ElevatedButton("Размытие", on_click=apply_blur_filter),
ft.ElevatedButton("Поворот", on_click=apply_rotate_filter),
],
visible=False,
alignment=ft.MainAxisAlignment.CENTER,
)
# --- СБОРКА ИНТЕРФЕЙСА ---
page.add(
ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER),
image_view,
filters_row
)
# --- ЗАПУСК ПРИЛОЖЕНИЯ ---
ft.app(target=main)
Домашнее задание: Что можно улучшить?
Поздравляю, у вас есть рабочее приложение! Но, как и любой хороший проект, его всегда можно сделать еще лучше. Эти задания помогут вам глубже понять Flet и добавить в редактор профессиональные функции. Раскройте каждое задание, чтобы увидеть условие.
1. Регулировка силы эффекта с помощью слайдера
Задача: Сейчас фильтр "Размытие" применяетcя с фиксированной силой. Замените его на ft.Slider, который позволит пользователю плавно регулировать степень размытия от 0 до 10.
Подсказки:
Добавьте виджет
ft.Sliderвfilters_row. Установите ему минимальное (min=0) и максимальное (max=10) значения.Вместо события
on_clickу кнопки используйте событиеon_changeу слайдера. Ваша функция-обработчик будет получать значение слайдера.В Pillow для регулируемого размытия используйте не
ImageFilter.BLUR, аImageFilter.GaussianBlur(radius=value), гдеvalue— это значение, пришедшее от слайдера.Не забудьте, что применять фильтр нужно к исходному изображению, а не к уже размытому, иначе эффект будет накапливаться с каждым движением слайдера.
2. Кнопка "Сброс" для отмены всех изменений
Задача: Пользователь может применить несколько фильтров подряд, но у него нет способа вернуться к оригиналу, кроме как открыть файл заново. Добавьте кнопку "Сброс", которая отменяет все примененные эффекты.
Подсказки:
Вам понадобится еще одна переменная состояния, помимо
current_image. Назовите ее, например,original_image.В функции
on_file_picker_result(при открытии файла) сохраняйте объект Pillow и вoriginal_image, и вcurrent_image.Функция-обработчик для кнопки "Сброс" должна будет просто скопировать объект из
original_imageвcurrent_imageи обновитьimage_view.
3. Добавление новых фильтров
Задача: Расширьте функциональность редактора, добавив как минимум два новых фильтра из библиотеки Pillow.
Подсказки:
Добавьте новые виджеты
ft.ElevatedButtonвfilters_row.Напишите для них новые функции-обработчики по аналогии с существующими.
Изучите документа��ию
ImageFilterв Pillow. Попробуйте реализовать, например,ImageFilter.CONTOUR(выделение контуров) илиImageFilter.SHARPEN(увеличение резкости). Они применяются так же просто, как и размытие.
4. Отображение информации об изображении
Задача: Сделайте приложение более информативным, добавив под изображением текстовую строку, которая показывает его размеры и формат. Например: "Размер: 1920x1080 | Формат: PNG".
Подсказки:
Добавьте виджет
ft.Textв основнойColumnвашего приложения, подimage_view. Изначально он может быть пустым или скрытым.В функции
on_file_picker_result, после загрузки изображения вcurrent_image, вы можете получить его свойства:current_image.size(это кортеж, например(1920, 1080)) иcurrent_image.format(это строка, например"PNG").Сформируйте нужную строку и установите ее как значение (
value) для вашегоft.Textвиджета, после чего обновите страницу.
Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.
Уверен, у вас все получится. Вперед, к практике!
Octagon77
Это всё замечательно, но если в статье упомянут Flutter, то нужно как-то это показать. Рисовать то на экране может каждый, а вот в документации по Flet написано
Иными словами, APK замучаешься получать, Flet сложным не заморачивается - TBD и отвали.
А если Автор, задним числом, попытается сказать, что статью писал не об этом, то тогда, в статье не об этом,
просто и тупо неверно - есть масса приемлимых альтернатив начиная с PyGame.