Статьи

Создаем кроссплатформенный калькулятор на Python с нуля при помощи Flet

Веб-разработка

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

Чтобы создать привлекательное мобильное приложение, которое будет отлично работать на Android и iOS, обычно требуется значительная доработка существующих инструментов, таких как Kivy или Tkinter. Именно здесь на сцену выходит Flet — фреймворк, который по сути является Python-оберткой над Flutter, UI-китом от Google. Он берет лучшее из двух миров: простоту и лаконичность Python для логики и всю мощь и красоту Flutter для интерфейса.

Давайте посмотрим, как создать базовое приложение-калькулятор с помощью Flet, и увидим, насколько простым и эффективным может быть этот фреймворк.

Что за Flet и почему он вам (возможно) нужен?

Если коротко, Flet позволяет вам описывать UI-компоненты Flutter на Python. Вы пишете код на Python, а Flet в реальном времени транслирует его в работающее приложение.

Ключевые преимущества:

  1. Настоящая кроссплатформенность: Один и тот же код работает как десктопное приложение (Windows, macOS, Linux), веб-приложение (WebAssembly) и мобильное приложение (Android, iOS).
  2. Простота входа: Если вы знаете Python, вы уже на 80% готовы к работе с Flet. Не нужно учить Dart (язык Flutter) или разбираться в тонкостях мобильной разработки.
  3. Современный UI из коробки: Приложения на Flet выглядят современно, потому что под капотом — Flutter с его виджетами Material Design и Cupertino.

Конечно, это не серебряная пуля. Для сверхсложных интерфейсов с кастомными анимациями, возможно, придется смотреть в сторону нативного Flutter. Но для 95% задач, от внутренних утилит до полноценных коммерческих приложений, Flet подходит идеально.

[!INFO] Для комфортного понимания материала будет плюсом, если вы хотя бы краем уха слышали о блочной модели (padding, margin) и flexbox-верстке. Flet активно использует эти концепции, и понимание основ сильно упростит вам жизнь.

Шаг 0: Подготавливаем окружение

Убедитесь, что у вас установлен Python (версии 3.7+ будет достаточно). Дальше все просто.

  1. Устанавливаем Flet. Открываем терминал и устанавливаем Flet.

    pip install flet
    
  2. Создаем файл. Откройте вашу любимую IDE (VSCode, PyCharm, etc.) и создайте файл, например, calculator.py.

  3. Проверка связи. Давайте запустим классический "Hello, World!", чтобы убедиться, что Flet завелся.

    import flet as ft
    
    def main(page: ft.Page):
        page.title = "Проверка связи"
        page.vertical_alignment = ft.MainAxisAlignment.CENTER
        
        page.add(
            ft.Text(value="Hello, Flet!", size=30)
        )
    
    if __name__ == "__main__":
        ft.app(target=main)
    

    Запускаем python calculator.py. Если вы видите окно с текстом "Hello, Flet!", значит, все готово. Можно приступать к сборке нашего калькулятора.

Шаг 1: Скелет приложения (UI Layout)

Любое приложение начинается с разметки. Нам нужно создать окно, в нем — дисплей для вывода цифр и сетку кнопок. В Flet это делается декларативно: мы описываем, что мы хотим видеть, а не как это рисовать.

Начнем с импортов и базовой структуры. Замените содержимое вашего файла calculator.py на это:

import flet as ft
import time

def main(page: ft.Page):
    # Настройки окна
    page.title = "Flet Calculator"
    page.window_resizable = False # Запрещаем менять размер окна
    page.window_width = 380
    page.window_height = 530
    page.theme_mode = ft.ThemeMode.DARK # Тема
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER # Центрируем контент

    # Здесь будет наш код...

    page.update()

if __name__ == "__main__":
    ft.app(target=main)

Мы задали заголовок, зафиксировали размер окна, включили темную тему и отцентрировали все по горизонтали. Теперь добавим сами элементы.

Фундамент: Основной контейнер

Все элементы нашего калькулятора будут жить внутри одного большого Container. Это аналог <div> из веба. Он позволяет задать фон, отступы, рамки и т.д.

Добавьте этот код внутрь функции main:

# ... (код настроек окна) ...

    # Основной контейнер калькулятора
    calculator_container = ft.Container(
        width=350,
        bgcolor=ft.Colors.BLACK,
        border_radius=ft.border_radius.all(20),
        padding=ft.padding.all(20),
        # content будет здесь
    )

    page.add(calculator_container)
    page.update()

Дисплей калькулятора

Для дисплея идеально подходит TextField. Мы сделаем его "только для чтения" (read_only=True), чтобы пользователь не мог вводить текст с клавиатуры.

# ... (код настроек окна) ...

    # Дисплей
    result_display = ft.TextField(
        value="0",
        text_align=ft.TextAlign.RIGHT,
        text_size=40,
        read_only=True,
        border_color="transparent", # Убираем рамку
    )
    
    # Основной контейнер калькулятора
    calculator_container = ft.Container(
        # ... (свойства контейнера) ...
        content=ft.Column(
            controls=[
                result_display,
                # Здесь будут кнопки
            ]
        )
    )

    page.add(calculator_container)
    # ...

Мы обернули наш дисплей в ft.Column. Это виджет, который располагает дочерние элементы друг под другом, по вертикали.

Кнопочная матрица

Кнопки мы будем располагать рядами с помощью виджета ft.Row. Каждый Row — это горизонтальный ряд кнопок.

Давайте определим кнопки, создав список кортежей с текстом для кнопок:

# ... (после определения result_display)

    # Определяем кнопки
    buttons = [
        ['C', '^', '%', '/'],
        ['7', '8', '9', '*'],
        ['4', '5', '6', '-'],
        ['1', '2', '3', '+'],
        ['0', '.', '=']
    ]

    button_rows = []
    for row_items in buttons:
        row_controls = []
        for item in row_items:
            # Пока просто создаем кнопки без логики
            button = ft.ElevatedButton(
                text=item,
                expand=1 # Растягиваем кнопку, чтобы занять доступное место
            )
            row_controls.append(button)
        
        # Для последней строки сделаем кнопку "=" двойной ширины
        if row_items[-1] == '=':
            row_controls[-1].expand = 2

        button_rows.append(ft.Row(controls=row_controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN))
    
    # ...
    # Заменяем содержимое Column в calculator_container
    content=ft.Column(
        controls=[
            result_display,
            *button_rows # Распаковываем список рядов кнопок
        ]
    )
    # ...

Мы создали кнопки в цикле и использовали свойство expand=1. Это мощный инструмент из Flexbox-модели. Он говорит элементу занять всю доступную "лишнюю" ширину в Row, разделив ее поровну с другими элементами, у которых тоже есть expand. Для кнопки = мы поставили expand=2, чтобы она стала в два раза шире.

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

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

Шаг 2: Оживляем кнопки (Добавляем логику)

Статичный интерфейс — это, конечно, красиво, но бесполезно. Нам нужно, чтобы приложение реагировало на нажатия кнопок. В Flet, как и в большинстве UI-фреймворков, это делается через обработчики событий (event handlers).

Мы создадим одну-единственную функцию, которая будет обрабатывать нажатия всех кнопок. А какая именно кнопка была нажата, мы узнаем из объекта события.

1. Создание функции-обработчика

Сначала определим саму функцию. Пока она будет пустой. Добавьте ее в main перед определением кнопок:

# ... (после определения result_display)

    def button_clicked(e: ft.ControlEvent):
        # Логика будет здесь
        print(f"Нажата кнопка: {e.control.text}") # Временно для отладки

    # Определяем кнопки
    buttons = [
        # ...
    ]
# ...

Эта функция принимает один аргумент e типа ft.ControlEvent. Через e.control мы получаем доступ к тому виджету, который вызвал событие (в нашем случае — к кнопке), а через e.control.text — к тексту на этой кнопке.

2. Подключение обработчика к кнопкам

Теперь нам нужно сказать каждой кнопке, чтобы при нажатии (on_click) она вызывала нашу функцию button_clicked. Модифицируем цикл создания кнопок:

# ... (внутри цикла for item in row_items:)
            button = ft.ElevatedButton(
                text=item,
                expand=1,
                on_click=button_clicked # <--- ВОТ ОНО
            )
# ...

Запустите код сейчас. Вы увидите работающий макет, и при нажатии на любую кнопку в консоли (терминале) будет появляться сообщение о том, какая кнопка нажата. Отлично, связь установлена!

3. Реализация логики калькулятора

Теперь наполним button_clicked смыслом. Нам нужно обрабатывать четыре типа ввода:

  1. 'C': Очистить дисплей.
  2. '=': Вычислить выражение.
  3. Цифры и точка: Добавить символ на дисплей.
  4. Операторы: Добавить оператор на дисплей.

Замените содержимое функции button_clicked на это:

    def button_clicked(e: ft.ControlEvent):
        data = e.control.text

        if data == "=":
            # Логика вычисления
            pass
        elif data == "C":
            result_display.value = "0"
        else:
            if result_display.value == "0":
                result_display.value = data
            else:
                result_display.value += data
        
        result_display.update()

Обратите внимание на result_display.update(). Этот вызов критически важен. Flet не перерисовывает интерфейс при каждом изменении переменной. Вы должны явно сказать ему, какой именно контрол нужно обновить на экране. Это делает Flet очень производительным, так как он не тратит ресурсы на перерисовку всего окна.

4. Вычисление результата

Самый простой и самый опасный способ вычислить строку с математическим выражением в Python — это функция eval().

[!DANGER] Осторожно, eval()! Использование eval() с данными, которые вводит пользователь — это огромная дыра в безопасности. eval() может выполнить любой код Python. Если пользователь введет в калькулятор __import__('os').system('rm -rf /'), eval без зазрения совести попытается это выполнить. Никогда не используйте eval() в продакшн-коде с непроверенными данными!

Но мы пойдем другим путем. Мы напишем простую и безопасную функцию вычисления, используя встроенный модуль ast (Abstract Syntax Tree). Он позволяет разобрать строку с кодом в безопасное "дерево", проверить, что в нем нет ничего опасного, и только потом выполнить.

Добавьте этот код снаружи функции main, например, в самом верху файла после импортов:

import flet as ft
import ast # Импортируем модуль AST

# Список разрешенных узлов AST
ALLOWED_NODES = [
    'Expression', 'Add', 'Sub', 'Mult', 'Div', 'Mod',
    'Pow', 'BinOp', 'UnaryOp', 'Num', 'Load', 'UAdd', 'USub'
]

# В Python 3.8+ ast.Num заменен на ast.Constant
# Добавим проверку версии для совместимости
try:
    # Python 3.8+
    from _ast import Constant
    ALLOWED_NODES.append('Constant')
except ImportError:
    pass


def safe_eval(expression):
    """
    Безопасно вычисляет математическое выражение,
    предотвращая выполнение вредоносного кода.
    """
    try:
        # Заменяем символ возведения в степень для совместимости с Python
        expression = expression.replace('^', '**')
        
        # Разбираем выражение в AST
        tree = ast.parse(expression, mode='eval')

        # Проверяем все узлы в дереве
        for node in ast.walk(tree):
            if node.__class__.__name__ not in ALLOWED_NODES:
                raise ValueError(f"Недопустимая операция: {node.__class__.__name__}")
        
        # Если все узлы разрешены, вычисляем
        return eval(compile(tree, '<string>', 'eval'))

    except (SyntaxError, ValueError, TypeError, ZeroDivisionError) as e:
        return "Ошибка"

Эта функция:

  1. Заменяет символ ^ на питоновский ** для возведения в степень.
  2. Парсит строку в AST.
  3. Проходит по всем "узлам" дерева и проверяет, есть ли они в нашем белом списке (ALLOWED_NODES). Если встречается что-то незнакомое (например, вызов функции import), она вызывает ошибку.
  4. Если все проверки пройдены, она компилирует безопасное дерево обратно в байт-код и выполняет его.

Теперь мы можем безопасно использовать ее в нашем обработчике.

5. Завершение логики

Вернемся в button_clicked и добавим вызов safe_eval:

    def button_clicked(e: ft.ControlEvent):
        data = e.control.text

        if data == "=":
            # Вычисляем с помощью нашей безопасной функции
            result = safe_eval(result_display.value)
            result_display.value = str(result)
        elif data == "C":
            result_display.value = "0"
        else:
            # Заменяем "0" на первое введенное число
            if result_display.value == "0" or result_display.value == "Ошибка":
                result_display.value = data
            else:
                result_display.value += data
        
        result_display.update()

Мы также добавили проверку, чтобы ввод нового числа заменял "0" или сообщение об ошибке.

Готово! Теперь у вас есть полностью функциональный и безопасный калькулятор. Он считает, очищает и обрабатывает ошибки. Остался последний штрих — навести красоту.

Шаг 3: Наведение лоска

Сейчас наш калькулятор выглядит немного серо и уныло. Flet предоставляет богатые возможности для стилизации. Давайте этим воспользуемся, чтобы сделать наш UI более интуитивным и современным.

1. Стилизация кнопок

Мы хотим, чтобы кнопки операторов (/, *, -, +), очистки (C) и вычисления (=) визуально отличались от кнопок с цифрами. Это стандартная практика в дизайне калькуляторов, которая помогает пользователю быстрее ориентироваться.

Для этого мы можем задать цвета фона (bgcolor) и текста (color) для каждой кнопки.

Модифицируем наш цикл создания кнопок. Вместо того чтобы создавать все кнопки одинаковыми, добавим немного логики для их стилизации.

# ... (внутри функции main)
    
    # Стили для разных типов кнопок
    button_styles = {
        "operator": ft.ButtonStyle(
            bgcolor=ft.Colors.ORANGE,
            color=ft.Colors.WHITE,
            shape=ft.RoundedRectangleBorder(radius=5)
        ),
        "clear": ft.ButtonStyle(
            bgcolor=ft.Colors.GREY,
            color=ft.Colors.BLACK,
            shape=ft.RoundedRectangleBorder(radius=5)
        ),
        "number": ft.ButtonStyle(
            bgcolor=ft.Colors.WHITE24,
            color=ft.Colors.WHITE,
            shape=ft.RoundedRectangleBorder(radius=5)
        )
    }

    # Определяем кнопки
    buttons = [
        ['C', '^', '%', '/'],
        ['7', '8', '9', '*'],
        ['4', '5', '6', '-'],
        ['1', '2', '3', '+'],
        ['0', '.', '=']
    ]

    button_rows = []
    for row_items in buttons:
        row_controls = []
        for item in row_items:
            # Определяем стиль кнопки
            style = button_styles["number"] # Стиль по умолчанию
            if item in ['/', '*', '-', '+', '^', '%']:
                style = button_styles["operator"]
            elif item == 'C':
                style = button_styles["clear"]
            
            button = ft.ElevatedButton(
                text=item,
                expand=1,
                on_click=button_clicked,
                style=style, # Применяем стиль
                height=60, # Задаем фиксированную высоту кнопок
            )
            row_controls.append(button)
        
        # Для последней строки сделаем кнопку "=" двойной ширины и другого цвета
        if row_items[-1] == '=':
            row_controls[-1].expand = 2
            row_controls[-1].style = ft.ButtonStyle(
                bgcolor=ft.Colors.ORANGE,
                color=ft.Colors.WHITE,
                shape=ft.RoundedRectangleBorder(radius=5)
            )

        button_rows.append(
            ft.Row(
                controls=row_controls, 
                alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
                spacing=10 # Добавляем отступ между кнопками в ряду
            )
        )

Что мы здесь сделали:

  1. Создали button_styles: Мы вынесли стили в отдельный словарь. Это делает код чище и позволяет легко менять дизайн в одном месте. Мы использовали ft.ButtonStyle для определения внешнего вида. shape позволяет задать скругление углов.
  2. Применяем стили в цикле: Внутри цикла мы проверяем, к какому типу относится кнопка (число, оператор, очистка), и применяем соответствующий стиль.
  3. Выделили кнопку =: Кнопка "равно" — самая важная, поэтому мы стилизовали ее отдельно, сделав такой же оранжевой, как и операторы.
  4. Добавили отступы: spacing=10 в ft.Row добавляет горизонтальный отступ между кнопками, а height=60 в ft.ElevatedButton делает их выше и удобнее для нажатия.

2. Финальные штрихи

Давайте также добавим вертикальные отступы между рядами кнопок и дисплеем, чтобы интерфейс не выглядел "слипшимся". Для этого можно использовать свойство spacing в ft.Column, который содержит все наши элементы.

Найдите определение calculator_container и измените его content:

    calculator_container = ft.Container(
        # ... (свойства контейнера) ...
        content=ft.Column(
            controls=[
                result_display,
                *button_rows
            ],
            spacing=10 # <--- Добавляем вертикальный отступ между элементами
        )
    )

Теперь наш калькулятор выглядит профессионально и законченно.

Итоги и полный код

Мы прошли весь путь от пустой папки до полнофункционального, безопасного и стильного кроссплатформенного приложения. И все это — на чистом Python, с помощью одного-единственного фреймворка.

Что мы изучили:

  • Основы Flet: Page, Container, Column, Row, TextField, ElevatedButton.
  • Лэйаут и верстка: Как располагать элементы с помощью expand, spacing и выравнивания.
  • Обработка событий: Как заставить приложение реагировать на действия пользователя с помощью on_click.
  • Безопасное вычисление: Как избежать опасностей eval(), используя модуль ast для парсинга выражений.
  • Стилизация: Как кастомизировать внешний вид приложения с помощью ButtonStyle, цветов и отступов.

Это лишь верхушка айсберга возможностей Flet. Вы можете добавить историю вычислений, научные функции, темы и многое другое. Главное — теперь у вас есть надежная база.

Полный код calculator.py

Вот финальная версия нашего кода, готовая к использованию.

import flet as ft
import ast

# Список разрешенных узлов AST для безопасного вычисления
ALLOWED_NODES = [
    'Expression', 'Add', 'Sub', 'Mult', 'Div', 'Mod',
    'Pow', 'BinOp', 'UnaryOp', 'Num', 'Load', 'UAdd', 'USub'
]

try:
    from _ast import Constant
    ALLOWED_NODES.append('Constant')
except ImportError:
    pass


def safe_eval(expression: str):
    """
    Безопасно вычисляет математическое выражение,
    предотвращая выполнение вредоносного кода.
    """
    try:
        # Заменяем символ возведения в степень для совместимости с Python
        expression = expression.replace('^', '**')
        
        tree = ast.parse(expression, mode='eval')

        for node in ast.walk(tree):
            if node.__class__.__name__ not in ALLOWED_NODES:
                raise ValueError(f"Недопустимая операция: {node.__class__.__name__}")
        
        return eval(compile(tree, '<string>', 'eval'))
    except Exception:
        return "Ошибка"


def main(page: ft.Page):
    # Настройки окна
    page.title = "Flet Calculator"
    page.window_resizable = False
    page.window_width = 380
    page.window_height = 530
    page.theme_mode = ft.ThemeMode.DARK
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    def button_clicked(e: ft.ControlEvent):
        data = e.control.text

        if data == "=":
            result = safe_eval(result_display.value)
            result_display.value = str(result)
        elif data == "C":
            result_display.value = "0"
        else:
            if result_display.value == "0" or result_display.value == "Ошибка":
                result_display.value = data
            else:
                result_display.value += data
        
        page.update()

    # Дисплей
    result_display = ft.TextField(
        value="0",
        text_align=ft.TextAlign.RIGHT,
        text_size=40,
        read_only=True,
        border_color="transparent",
    )

    # Стили для разных типов кнопок
    button_styles = {
        "operator": ft.ButtonStyle(bgcolor=ft.Colors.ORANGE, color=ft.Colors.WHITE, shape=ft.RoundedRectangleBorder(radius=5)),
        "clear": ft.ButtonStyle(bgcolor=ft.Colors.GREY, color=ft.Colors.BLACK, shape=ft.RoundedRectangleBorder(radius=5)),
        "number": ft.ButtonStyle(bgcolor=ft.Colors.WHITE24, color=ft.Colors.WHITE, shape=ft.RoundedRectangleBorder(radius=5))
    }

    # Матрица кнопок
    buttons = [
        ['C', '^', '%', '/'], ['7', '8', '9', '*'],
        ['4', '5', '6', '-'], ['1', '2', '3', '+'],
        ['0', '.', '=']
    ]

    button_rows = []
    for row_items in buttons:
        row_controls = []
        for item in row_items:
            style = button_styles["number"]
            if item in ['/', '*', '-', '+', '^', '%']:
                style = button_styles["operator"]
            elif item == 'C':
                style = button_styles["clear"]
            
            button = ft.ElevatedButton(
                text=item, expand=1, on_click=button_clicked,
                style=style, height=60,
            )
            row_controls.append(button)
        
        if row_items[-1] == '=':
            row_controls[-1].expand = 2
            row_controls[-1].style = ft.ButtonStyle(bgcolor=ft.Colors.ORANGE, color=ft.Colors.WHITE, shape=ft.RoundedRectangleBorder(radius=5))

        button_rows.append(ft.Row(controls=row_controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10))

    # Основной контейнер
    calculator_container = ft.Container(
        width=350,
        bgcolor=ft.colors.BLACK,
        border_radius=ft.border_radius.all(20),
        padding=ft.padding.all(20),
        content=ft.Column(
            controls=[result_display, *button_rows],
            spacing=10
        )
    )

    page.add(calculator_container)
    page.update()

if __name__ == "__main__":
    ft.app(target=main)

Попробуйте расширить этот калькулятор, добавить новые функции или, что еще интереснее, собрать свое собственное приложение. Удачи!