From 7435992e15f2c12a905c645d4fb091520f407b61 Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 6 Apr 2026 15:40:06 +0500 Subject: [PATCH] push web --- README.md | 506 +++++++++++++++++++++- web/designer.py | 166 ++++--- web/main.py | 7 +- web/radar_chart.py | 4 +- web/router.py | 18 + web/theme_manager.py | 178 ++++++++ web/views/content_users/main_page.py | 4 +- web/views/content_users/my_tests_page.py | 6 +- web/views/content_users/poll_page.py | 64 +-- web/views/content_users/profile_page.py | 110 ++++- web/views/content_users/response_page.py | 10 +- web/views/content_users/take_test_page.py | 4 +- web/views/login_view.py | 10 +- web/views/main_view.py | 6 +- web/views/my_tests_view.py | 5 +- web/views/poll_view.py | 7 +- web/views/profile_view.py | 5 +- web/views/reg_view.py | 10 +- web/views/response_view.py | 5 +- web/views/take_test_view.py | 8 +- web/views/user_view.py | 10 +- 21 files changed, 993 insertions(+), 150 deletions(-) create mode 100644 web/theme_manager.py diff --git a/README.md b/README.md index 8b7ad8f..43cd06b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,510 @@ # api-copp -Веб-приложение для создания, проведения и анализа психологических тестов. Включает API-сервер на FastAPI и пользовательский интерфейс на Flet. Поставляется в Docker и поддерживает готовый тест по методике профессиональной ориентации Голланда. +Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. Включает REST API на FastAPI и пользовательский интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. + +--- + +## Содержание + +- [Архитектура](#архитектура) +- [Быстрый старт](#быстрый-старт) +- [Переменные окружения](#переменные-окружения) +- [Структура проекта](#структура-проекта) +- [База данных](#база-данных) +- [API](#api) +- [Тест Голланда](#тест-голланда) +- [Паутинная диаграмма](#паутинная-диаграмма) +- [Фронтенд](#фронтенд) +- [Дизайн-система](#дизайн-система) +- [Деплой с внешним прокси](#деплой-с-внешним-прокси) +- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты) + +--- + +## Архитектура + +``` +Пользователь (браузер) + │ + │ :80 + [ Внешний прокси / nginx ] ← необязателен для локальной сети + │ + │ :80 + [ web — Flet ASGI ] ← единственный публичный сервис + │ + │ http://api:8000 (только внутри Docker-сети) + [ api — FastAPI ] + │ │ + [ PostgreSQL :5432 ] [ Redis :6379 ] +``` + +**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую. + +**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network. + +--- + +## Быстрый старт + +### 1. Инфраструктура (PostgreSQL + Redis) + +```bash +docker compose -f docker-compose_db.yml up -d +``` + +### 2. Приложение (API + Web) + +```bash +docker compose up -d --build +``` + +После запуска: + +| Адрес | Что | +|---|---| +| `http://localhost` | Веб-интерфейс (Flet) | +| `http://localhost:8000/docs` | Swagger UI — **только с хоста сервера**, порт не опубликован | +| `http://localhost:8000/redoc` | ReDoc | + +> В продакшне API недоступен снаружи. Для работы с `/docs` используйте SSH-туннель: +> ```bash +> ssh -L 8000:localhost:8000 user@your-server +> ``` + +Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`. + +### 3. Инициализация БД + +После первого запуска выполните (с хоста сервера или через туннель): + +```bash +# Создать таблицы +curl -X POST http://localhost:8000/db/create-tables \ + -H "X-Admin-Key: " + +# Применить миграции (добавляет колонки, если БД уже была создана ранее) +curl -X POST http://localhost:8000/db/migrate \ + -H "X-Admin-Key: " +``` + +### 4. Загрузка теста Голланда + +```bash +# Создать тест (возвращает poll_id) +curl -X POST http://localhost:8000/polls/holland \ + -H "X-Admin-Key: " + +# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов) +curl -X POST http://localhost:8000/polls/holland//seed-scores \ + -H "X-Admin-Key: " +``` + +--- + +## Переменные окружения + +> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД. + +| Переменная | Описание | Пример | +|---|---|---| +| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/app` | +| `DB_HOST` | Хост PostgreSQL | `postgres` | +| `DB_PORT` | Порт PostgreSQL | `5432` | +| `DB_USER` | Пользователь БД | `postgres` | +| `DB_PASS` | Пароль БД | `postgres` | +| `DB_NAME` | Имя базы данных | `profi` | +| `REDIS_HOST` | Хост Redis | `redis` | +| `REDIS_PORT` | Порт Redis | `6379` | +| `REDIS_DB` | Номер БД Redis | `0` | +| `REDIS_PASSWORD` | Пароль Redis | — | +| `SECRET_KEY` | Секрет для подписи JWT-токенов (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` | +| `ADMIN_KEY` | Ключ для защищённых admin-эндпоинтов | — | +| `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` | +| `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — | +| `API_URL` | Внутренний URL API для фронтенда (только Docker-сеть) | `http://api:8000` | + +Файл `docker-compose.yml` содержит **демо-значения** — не используйте их в продакшн. + +--- + +## Структура проекта + +``` +api-copp/ +├── main.py # точка входа FastAPI, регистрация роутеров +├── requirements.txt # зависимости API +├── pyproject.toml # метаданные проекта +├── Dockerfile.api # образ API (python:3.14-slim) +├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet), порт 80 +├── docker-compose.yml # API (без портов) + Web (порт 80) +├── docker-compose_db.yml # PostgreSQL + Redis +├── nginx.conf # пример конфига nginx (для внешнего прокси) +│ +├── bd/ # слой доступа к данным +│ ├── __init__.py # Settings (pydantic), make_engine() +│ └── tables/ +│ ├── simple_base.py # общий Base для SQLAlchemy +│ ├── users.py # User +│ ├── organization.py # Organization +│ ├── group.py # Group +│ ├── poll.py # Poll +│ ├── question.py # Question +│ ├── choice.py # Choice +│ ├── response.py # Response, Answer +│ ├── scale.py # ScaleDimension, ChoiceScore +│ └── radar_result.py # RadarResult, RadarResultItem +│ +├── route/ # FastAPI-роутеры +│ ├── auth_utils.py # JWT, bcrypt, require_admin_key +│ ├── auth.py # /auth/register, /auth/login +│ ├── base.py # /health, /version +│ ├── init_data_base.py # /db/create-tables, /db/migrate +│ ├── users_crud.py # CRUD пользователей +│ ├── groups_crud.py # CRUD групп +│ ├── organizations_crud.py# CRUD организаций +│ ├── poll_crud.py # CRUD опросов +│ ├── question_crud.py # CRUD вопросов +│ ├── choice_crud.py # CRUD вариантов ответов +│ ├── response_crud.py # приём ответов + авто-расчёт радара +│ ├── holland_crud.py # генерация теста Голланда + seed-scores +│ ├── glomshtok_crud.py # генерация теста Гломштока +│ ├── klimov_crud.py # генерация теста Климова +│ ├── scale_crud.py # CRUD осей и расшифровок +│ ├── radar_crud.py # расчёт и чтение радарных результатов +│ └── radar_svg_gen.py # серверная генерация SVG-диаграмм +│ +├── web/ # Flet-фронтенд +│ ├── main.py # точка входа Flet ASGI + применение темы +│ ├── router.py # маршрутизация страниц +│ ├── api_client.py # httpx-обёртки над API (API_URL из env) +│ ├── designer.py # дизайн-система: colors, компоненты +│ ├── theme_manager.py # менеджер тем, сохранение в client_storage +│ ├── radar_chart.py # виджет RadarChart (flet.canvas) +│ ├── radar_svg.py # генератор SVG (устаревший, не используется) +│ └── views/ +│ ├── login_view.py +│ ├── reg_view.py +│ ├── user_view.py +│ ├── profile_view.py +│ ├── poll_view.py +│ ├── response_view.py +│ ├── my_tests_view.py +│ ├── take_test_view.py +│ └── content_users/ +│ ├── main_page.py # главная: «Все тесты» / «Мои тесты» +│ ├── poll_page.py # прохождение теста +│ ├── response_page.py # результат с паутинной диаграммой +│ ├── my_tests_page.py # список пройденных тестов +│ ├── take_test_page.py # список доступных тестов +│ └── profile_page.py # редактирование профиля + выбор темы +│ +└── data/ # смонтированный том Docker (./data:/app/data) + ├── postgres/ # данные PostgreSQL + └── radar/ # сохранённые SVG-файлы диаграмм +``` + +--- + +## База данных + +### Схема таблиц + +``` +organizations ──< groups ──< users + │ +polls ──< questions ──< choices ──< choice_scores + │ │ + └──< responses ──< answers │ + │ │ + └──< radar_results ──< radar_result_items + (image_path) └── (dimension_id FK → scale_dimensions) + ↑ +polls ──< scale_dimensions ──────────────────-─┘ +``` + +### Ключевые таблицы + +| Таблица | Описание | +|---|---| +| `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` | +| `polls` | Тест/опрос: `title`, `description` | +| `questions` | Вопросы теста с `type` (`single` / `multiple`) | +| `choices` | Варианты ответов к вопросам | +| `responses` | Один факт прохождения теста (user + poll + timestamp) | +| `answers` | Конкретные ответы внутри прохождения | +| `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` | +| `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось | +| `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) | +| `radar_result_items` | Снимок значений по каждой оси на момент расчёта | + +### Миграции + +Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции: + +- Добавление колонок `username` / `hashed_password` в `users` +- Добавление колонки `image_path` в `radar_results` + +--- + +## API + +### Аутентификация + +``` +POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.) +POST /auth/login — получить JWT-токен (OAuth2 password flow) +``` + +JWT-токен передаётся заголовком `Authorization: Bearer `. Admin-операции требуют заголовок `X-Admin-Key`. + +### Основные эндпоинты + +``` +# Тесты +GET /polls — список тестов +POST /polls — создать тест +GET /polls/{id} — тест с вопросами и вариантами +DELETE /polls/{id} — удалить тест + +# Вопросы / Варианты ответов +POST /polls/{id}/questions +POST /questions/{id}/choices + +# Прохождение +POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар) +GET /polls/users/{user_id}/responses — история пользователя +GET /responses/{id} — конкретный результат + +# Результаты (радар) +POST /responses/{id}/radar/compute — (пере)считать радарный результат +GET /responses/{id}/radar — данные для диаграммы +GET /responses/{id}/radar/image — SVG-файл диаграммы +GET /users/{user_id}/radar-results — история радарных результатов + +# Оси и расшифровки +POST /polls/{poll_id}/dimensions — создать ось +GET /polls/{poll_id}/dimensions — список осей +PUT /dimensions/{id} +DELETE /dimensions/{id} +POST /dimensions/{id}/scores — задать расшифровку варианта ответа +GET /choices/{choice_id}/scores + +# Готовые тесты (admin) +POST /polls/holland — создать тест Голланда (42 пары) +POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки Голланда +POST /polls/glomshtok — создать тест Гломштока +POST /polls/klimov — создать тест Климова + +# База данных (admin) +POST /db/create-tables +POST /db/migrate + +# Служебные +GET /health +GET /version +``` + +--- + +## Тест Голланда + +Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре. + +### Типы личности + +| Код | Название | Цвет | +|---|---|---| +| Р | Реалистичный | `#FF8C00` | +| И | Интеллектуальный | `#4169E1` | +| С | Социальный | `#32CD32` | +| К | Конвенциональный | `#9370DB` | +| П | Предприимчивый | `#DC143C` | +| А | Артистический | `#FF69B4` | + +Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями. + +### Процедура загрузки + +1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами +2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore` + +--- + +## Паутинная диаграмма + +### Как работает + +При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически: + +1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов +2. Сохраняет результат в `radar_results` + `radar_result_items` +3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg` +4. Записывает относительный путь в `image_path` — всё в одном `commit()` + +### Скачивание SVG + +Фронтенд получает SVG-байты с API через внутреннюю Docker-сеть (`api_client.get_radar_svg_bytes()`), кодирует в `data:application/octet-stream;base64,...` и передаёт в `page.launch_url()`. Браузер скачивает файл **напрямую из data URI** — никаких запросов к API из браузера. + +### Генерация SVG + +SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`): + +- 5 концентрических сеток +- Подписи с именем типа и баллом +- Размер холста: 520×520 пикселей + +--- + +## Фронтенд + +Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80. + +### Маршруты + +| Путь | Страница | +|---|---| +| `/login` | Вход | +| `/reg` | Регистрация | +| `/user/{url_key}` | Главная страница пользователя | +| `/take_test/{url_key}` | Список тестов для прохождения | +| `/poll/{url_key}/{poll_id}` | Прохождение теста | +| `/response/{url_key}/{response_id}` | Результат с диаграммой | +| `/my_tests/{url_key}` | Мои результаты | +| `/profile/{url_key}` | Профиль пользователя + выбор темы | + +`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`). Обращение к защищённому маршруту с чужим ключом перенаправляет на `/login`. + +### Варианты ответов в тесте + +Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py`). При выборе варианта: +- фон меняется с `surface_alt` → `primary_light` +- иконка меняется с `RADIO_BUTTON_UNCHECKED` → `CHECK_CIRCLE` + +--- + +## Дизайн-система + +Все UI-компоненты сосредоточены в `web/designer.py`. Управление темой — в `web/theme_manager.py`. + +### Глобальный масштаб + +```python +SCALE: float = 1.7 # 1.0 = 100%, меняйте только эту константу +s(14) # → round(14 * 1.7) = 24px +``` + +### Цветовая палитра (стиль copp74.ru) + +| Имя | HEX | Применение | +|---|---|---| +| `colors.primary` | `#F85A40` | Кнопки, акценты, логотип | +| `colors.primary_dark` | `#D94530` | Hover-состояние кнопок | +| `colors.primary_light` | `#FFEAB7` | Выбранный вариант ответа | +| `colors.background` | `#F4F6F8` | Фон страниц | +| `colors.surface` | `#FFFFFF` | Карточки, формы | +| `colors.surface_alt` | `#F0F2F5` | Невыбранные варианты, значки | +| `colors.accent` | `#FFC845` | Жёлтый акцент | +| `colors.info` | `#007FBD` | Синие кнопки скачивания | +| `colors.success` | `#85C446` | Зелёный (успех) | +| `colors.text_primary` | `#1E2A3A` | Основной текст | +| `colors.text_secondary` | `#5A6779` | Вторичный текст, плейсхолдеры | +| `colors.text_on_primary` | `#FFFFFF` | Текст на оранжевых кнопках | +| `colors.border` | `#DDE3EA` | Границы полей | + +### Компоненты + +| Класс | Наследует | Описание | +|---|---|---| +| `CastomText` | `ft.Text` | Текст с масштабированием | +| `CastomTextField_input` | `ft.TextField` | Поле ввода, белый фон, оранжевый фокус | +| `CastomButton` | `ft.Button` | Оранжевая кнопка с белым текстом | +| `CastomIconButton` | `ft.Button` | Синяя кнопка с иконкой | +| `CastomDropdown` | `ft.Dropdown` | Выпадающий список | +| `CastomSwitch` | `ft.Switch` | Переключатель | +| `AlterDiaalogApproval` | `ft.AlertDialog` | Диалог пользовательского соглашения | + +### Темы интерфейса + +Пользователь может выбрать тему в профиле. Тема сохраняется в `client_storage` браузера и восстанавливается при следующем входе. + +| ID | Название | Фон | +|---|---|---| +| `default` | ЦОПП (orange) | `#F4F6F8` | +| `light` | Светлая | `#FFFFFF` | +| `dark_blue` | Синяя | `#E8F4FD` | +| `green` | Зелёная | `#EDF7E7` | + +--- + +## Деплой с внешним прокси + +В репозитории есть готовый файл `nginx.conf` для случая, когда nginx развёрнут на отдельном сервере или уже управляет несколькими сайтами. + +Минимальная конфигурация upstream в вашем nginx: + +```nginx +location / { + proxy_pass http://:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; +} +``` + +Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket. + +--- + +## Известные ошибки и просчёты + +### 1. Лишнее дублирование генерации SVG + +**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент). + +**Решение:** Кнопка «Сохранить» скачивает SVG через `api_client.get_radar_svg_bytes()`. `web/radar_svg.py` больше не используется, но файл остался — можно удалить при следующем рефакторинге. + +--- + +### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено + +**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, но реально используется синхронный `pg8000`. + +**Решение:** Зависимость заменена на `pg8000>=1.29.0`. + +--- + +### 3. Нет транзакции при сохранении SVG ✅ исправлено + +**Проблема:** `image_path` обновлялся отдельным `commit()` после основного — при падении между ними запись оставалась без пути к файлу. + +**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `session.commit()`. + +--- + +### 4. Перенос подписей в диаграмме по буквам ✅ исправлено + +**Решение:** `_W_LBL` увеличен до 130, добавлен `max_lines=2, no_wrap=False`. + +--- + +### 5. Текст вариантов ответов обрезался ✅ исправлено + +**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты ответов обрезались. + +**Решение:** Реализованы кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`. + +--- + +### 6. Ошибка `module 'designer' has no attribute 'clolors'` ✅ исправлено + +**Проблема:** Класс цветов был назван `clolors` с опечаткой. После переименования в `colors` все ссылки в 10+ файлах указывали на старое имя. + +**Решение:** Класс переименован в `colors`, все ссылки во всех view-файлах обновлены. + +--- + --- diff --git a/web/designer.py b/web/designer.py index 0d5c693..5d2355f 100644 --- a/web/designer.py +++ b/web/designer.py @@ -1,7 +1,8 @@ import flet as ft # Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%. -# Меняйте только эту константу, чтобы масштабировать весь UI. +# Меняйте только эту переменную (или через theme_manager.apply_scale), +# чтобы масштабировать весь UI. SCALE: float = 1.7 @@ -10,15 +11,44 @@ def s(v: int | float) -> int: return round(v * SCALE) -class clolors: - background = "#C0DE9E" - laer1 = "#FFEAB7" - laer2 = "#D9F1F4" - laer3 = "#D2EA32" - ferst = "#85C446" +class colors: + # Основная палитра в стиле copp74.ru + primary = "#F85A40" # Основной оранжево-красный (логотип ЦОПП) + primary_dark = "#D94530" # Тёмный для hover/активных состояний + primary_light = "#FFEAB7" # Светлый персиковый (вторичный фон) + + # Фоны + background = "#F4F6F8" # Светло-серый фон страницы + surface = "#FFFFFF" # Белый для карточек и форм + surface_alt = "#F0F2F5" # Альтернативные блоки + + # Акцент — жёлтый + accent = "#FFC845" # Жёлтый акцент + accent_lime = "#FFC845" # Алиас для совместимости + secondary_light = "#FFEAB7" # Алиас для совместимости + secondary_yellow = "#FFC845" # Алиас + + # Синий — информация, ссылки + info = "#007FBD" # Синий + info_light = "#D9F1F4" # Светлый голубой + tertiary_light = "#D9F1F4" # Алиас для совместимости + tertiary_blue = "#007FBD" # Алиас для совместимости + secondary_orange = "#F85A40" # Алиас для совместимости + + # Зелёный — успех + success = "#85C446" # Зелёный + success_light = "#C0DE9E" # Светло-зелёный + + # Текст + text_primary = "#1E2A3A" # Почти чёрный для заголовков + text_secondary = "#5A6779" # Серый для второстепенного текста + text_on_primary = "#FFFFFF" # Белый текст на оранжевом фоне + + # Граница + border = "#DDE3EA" # Светло-серая граница class CastomText(ft.Text): - def __init__(self, text, size=14, color=clolors.ferst, weight=ft.FontWeight.NORMAL): + def __init__(self, text, size=14, color=colors.text_primary, weight=ft.FontWeight.NORMAL): super().__init__( value=text, color=color, @@ -33,25 +63,26 @@ class CastomTextField_input(ft.TextField): super().__init__( label=label, hint_text=hint_text, - filled=True, # заливка фона - bgcolor=clolors.ferst, # цвет заливки - border_color=clolors.laer2, # цвет границы - border_radius=s(12), # скругление углов + filled=True, + bgcolor=colors.surface, + border_color=colors.border, + focused_border_color=colors.primary, + border_radius=s(10), label_style=ft.TextStyle( - color=clolors.laer3, - size=s(14), - weight=ft.FontWeight.BOLD, + color=colors.text_secondary, + size=s(13), + weight=ft.FontWeight.W_500, ), hint_style=ft.TextStyle( - color=clolors.laer1, + color=colors.border, size=s(14), italic=True, ), text_style=ft.TextStyle( - color=clolors.laer3, - size=s(16), + color=colors.text_primary, + size=s(15), ), - content_padding=ft.padding.symmetric(horizontal=s(12), vertical=s(14)), + content_padding=ft.padding.symmetric(horizontal=s(14), vertical=s(14)), prefix_icon=prefix_icon, suffix_icon=suffix_icon, on_change=on_change, @@ -62,14 +93,20 @@ class CastomTextField_input(ft.TextField): class CastomButton(ft.Button): def __init__(self, text, on_click): super().__init__( - content=text, + content=ft.Text( + text, + color=colors.text_on_primary, + size=s(14), + weight=ft.FontWeight.W_600, + ) if isinstance(text, str) else text, style=ft.ButtonStyle( - color=clolors.ferst, - bgcolor=clolors.laer1, - text_style=ft.TextStyle( - size=s(14) - ), - padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)), + color=colors.text_on_primary, + bgcolor={ + ft.ControlState.DEFAULT: colors.primary, + ft.ControlState.HOVERED: colors.primary_dark, + }, + shape=ft.RoundedRectangleBorder(radius=s(10)), + padding=ft.padding.symmetric(horizontal=s(20), vertical=s(12)), ), on_click=on_click ) @@ -83,15 +120,17 @@ class CastomIconButton(ft.Button): spacing=s(8), tight=True, controls=[ - ft.Icon(icon, color=clolors.ferst, size=s(18)), - ft.Text(text, color=clolors.ferst, size=s(14)), + ft.Icon(icon, color=colors.text_on_primary, size=s(18)), + ft.Text(text, color=colors.text_on_primary, size=s(14), weight=ft.FontWeight.W_600), ], ), style=ft.ButtonStyle( - color=clolors.ferst, - bgcolor=clolors.laer2, - text_style=ft.TextStyle(size=s(14)), - padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)), + bgcolor={ + ft.ControlState.DEFAULT: colors.info, + ft.ControlState.HOVERED: "#006AA3", + }, + shape=ft.RoundedRectangleBorder(radius=s(10)), + padding=ft.padding.symmetric(horizontal=s(18), vertical=s(12)), ), on_click=on_click, ) @@ -103,27 +142,27 @@ class CastomDropdown(ft.Dropdown): hint_text=hint_text, options=options or [], filled=True, - fill_color=clolors.laer3, - color=clolors.ferst, - border_color=clolors.laer2, - border_radius=s(12), - focused_border_color=clolors.laer1, + fill_color=colors.surface, + color=colors.text_primary, + border_color=colors.border, + border_radius=s(10), + focused_border_color=colors.primary, label_style=ft.TextStyle( - color=clolors.ferst, - size=s(14), - weight=ft.FontWeight.BOLD, + color=colors.text_secondary, + size=s(13), + weight=ft.FontWeight.W_500, ), hint_style=ft.TextStyle( - color=clolors.laer1, + color=colors.border, size=s(14), italic=True, ), text_style=ft.TextStyle( - color=clolors.ferst, - size=s(16), + color=colors.text_primary, + size=s(15), ), menu_style=ft.MenuStyle( - bgcolor=clolors.laer3, + bgcolor=colors.surface, ), ) if on_change is not None: @@ -134,11 +173,11 @@ class CastomSwitch(ft.Switch): def __init__(self, label, on_change): super().__init__( label=label, - label_text_style=ft.TextStyle(size=s(12), color=clolors.ferst), - active_color=clolors.ferst, - active_track_color=clolors.laer1, - inactive_thumb_color=clolors.laer2, - inactive_track_color=clolors.laer3, + label_text_style=ft.TextStyle(size=s(13), color=colors.text_primary), + active_color=colors.primary, + active_track_color=colors.primary_light, + inactive_thumb_color=colors.border, + inactive_track_color=colors.surface_alt, on_change=on_change ) @@ -148,7 +187,7 @@ class AlterDiaalogApproval(ft.AlertDialog): self._on_accept = on_accept self.switch_approval = CastomSwitch( - label="Я согласен с условиями пользовательского соглашения", + label="", on_change=self.switch_changed ) @@ -159,15 +198,14 @@ class AlterDiaalogApproval(ft.AlertDialog): self.button_approval.disabled = True super().__init__( - bgcolor=clolors.laer3, + bgcolor=colors.surface, title=ft.Text( "Пользовательское соглашение", - color=clolors.ferst, + color=colors.primary, size=s(16), weight=ft.FontWeight.BOLD, ), content=ft.Container( - width=s(350), height=s(400), content=ft.Column( scroll=ft.ScrollMode.AUTO, @@ -293,11 +331,11 @@ class AlterDiaalogApproval(ft.AlertDialog): """, expand=True, md_style_sheet=ft.MarkdownStyleSheet( - p_text_style=ft.TextStyle(color=clolors.ferst, size=s(12)), - h1_text_style=ft.TextStyle(color=clolors.ferst, size=s(18), weight=ft.FontWeight.BOLD), - h2_text_style=ft.TextStyle(color=clolors.ferst, size=s(15), weight=ft.FontWeight.BOLD), - h3_text_style=ft.TextStyle(color=clolors.laer1, size=s(13), weight=ft.FontWeight.BOLD), - a_text_style=ft.TextStyle(color=clolors.laer1), + p_text_style=ft.TextStyle(color=colors.text_primary, size=s(12)), + h1_text_style=ft.TextStyle(color=colors.primary, size=s(18), weight=ft.FontWeight.BOLD), + h2_text_style=ft.TextStyle(color=colors.info, size=s(15), weight=ft.FontWeight.BOLD), + h3_text_style=ft.TextStyle(color=colors.text_secondary, size=s(13), weight=ft.FontWeight.BOLD), + a_text_style=ft.TextStyle(color=colors.primary), ), ) ] @@ -305,16 +343,20 @@ class AlterDiaalogApproval(ft.AlertDialog): ), actions=[ ft.Column( - width=s(350), horizontal_alignment=ft.CrossAxisAlignment.CENTER, controls=[ ft.Row( expand=True, alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[ - ft.Container( - width=s(330), - content=self.switch_approval, + self.switch_approval, + ft.Text( + "Я согласен с условиями пользовательского соглашения", + size=s(13), + color=colors.text_primary, + expand=True, + no_wrap=False, ), ] ), diff --git a/web/main.py b/web/main.py index bbf6bd3..d0d8b45 100644 --- a/web/main.py +++ b/web/main.py @@ -3,6 +3,7 @@ import flet as ft import httpx import designer as ds from router import setup_router +import theme_manager as tm from starlette.applications import Starlette from starlette.routing import Route, Mount from starlette.responses import Response as StarletteResponse @@ -29,7 +30,11 @@ async def _radar_image(request): async def main(page: ft.Page): page.title = "COPP" - page.bgcolor = ds.clolors.background + + # Применяем сохранённые тему и масштаб до построения любых виджетов + tm.apply_scale(page) + tm.apply_theme(page) + # Масштабирование глобального text_theme синхронно с ds.SCALE: # покрывает ft.Text() без явного size (body_medium по умолчанию = 14) _ts = lambda base: ft.TextStyle(size=ds.s(base)) diff --git a/web/radar_chart.py b/web/radar_chart.py index a00766b..8681ee5 100644 --- a/web/radar_chart.py +++ b/web/radar_chart.py @@ -95,7 +95,7 @@ class RadarChart(ft.Stack): a = math.radians(-90.0 + 360.0 * i / n) lx = ccx + _LBL_R * math.cos(a) ly = ccy + _LBL_R * math.sin(a) - col = item.get("dimension_color") or ds.clolors.ferst + col = item.get("dimension_color") or ds.colors.primary labels.append(ft.Container( left=lx - _W_LBL / 2, @@ -119,7 +119,7 @@ class RadarChart(ft.Stack): ft.Text( str(int(item["value"])), size=11, - color=ds.clolors.ferst, + color=ds.colors.primary, text_align=ft.TextAlign.CENTER, ), ], diff --git a/web/router.py b/web/router.py index 6fccbf6..e8360aa 100644 --- a/web/router.py +++ b/web/router.py @@ -1,5 +1,6 @@ import flet as ft from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES +import theme_manager as tm async def _redirect_to_login(page: ft.Page): @@ -42,6 +43,10 @@ async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent): views = resolve_view(page, route) + # Применяем сохранённые настройки пользователя перед построением виджетов + tm.apply_scale(page) + tm.apply_theme(page) + # Неизвестный маршрут → на страницу пользователя if views is None: await page.push_route(f"/user/{url_key}") @@ -62,4 +67,17 @@ def setup_router(page: ft.Page): async def _on_route_change(e: ft.RouteChangeEvent): await _handle_route_change(page, e) + def _on_resize(e): + """Пересчитать масштаб при изменении размера окна/повороте телефона, + но только если пользователь не менял его вручную (нет сохранён. префа).""" + uid = tm._get_uid(page) + if uid not in tm._user_prefs: + new_scale = tm.auto_scale(page) + if abs(new_scale - (page.session.store.get("ds_scale_last") or 0)) > 0.05: + page.session.store.set("ds_scale_last", new_scale) + tm.apply_scale(page, new_scale) + # перезагрузим текущий маршрут чтобы виджеты пересоздались + page.go(page.route) + page.on_route_change = _on_route_change + page.on_resize = _on_resize diff --git a/web/theme_manager.py b/web/theme_manager.py new file mode 100644 index 0000000..97b07f8 --- /dev/null +++ b/web/theme_manager.py @@ -0,0 +1,178 @@ +"""Менеджер тем и масштаба приложения с сохранением в localStorage браузера.""" +import flet as ft +import designer as ds + + +# --------------------------------------------------------------------------- +# Темы: каждый цвет surface должен КОНТРАСТИРОВАТЬ с background, +# чтобы карточки, поля и кнопки не сливались с фоном. +# --------------------------------------------------------------------------- +THEMES: dict[str, dict] = { + "default": { + "name": "ЦОПП (оранжевая)", + # фон страницы + "background": "#F4F6F8", + # карточки / формы — светлее фона + "surface": "#FFFFFF", + # альтернативные блоки — чуть темнее фона + "surface_alt": "#E4E8ED", + }, + "warm": { + "name": "Тёплая", + "background": "#FFF3E6", + "surface": "#FFFFFF", + "surface_alt": "#FFD9B3", + }, + "blue": { + "name": "Синяя", + "background": "#E3F2FD", + "surface": "#FFFFFF", + "surface_alt": "#B8D9F5", + }, + "green": { + "name": "Зелёная", + "background": "#EDF7E7", + "surface": "#FFFFFF", + "surface_alt": "#BEE3B0", + }, + "dark": { + "name": "Тёмная", + "background": "#1E2A3A", + "surface": "#2D3F54", + "surface_alt": "#3D5068", + }, +} + +_SCALE_KEY = "ui_scale" +_THEME_KEY = "ui_theme" + +# Допустимый диапазон масштаба +SCALE_MIN = 0.8 +SCALE_MAX = 2.5 +SCALE_STEP = 0.1 +SCALE_DEFAULT = 1.7 + +# Автомасштаб по ширине экрана +_MOBILE_BREAKPOINT = 600 # px: меньше — телефон, больше — ПК/планшет +SCALE_MOBILE = 1.0 +SCALE_DESKTOP = 2.0 + + +def auto_scale(page: ft.Page) -> float: + """Вернуть масштаб на основе ширины viewport браузера.""" + try: + w = page.width or 0 + return SCALE_MOBILE if w < _MOBILE_BREAKPOINT else SCALE_DESKTOP + except Exception: + return SCALE_DEFAULT + + +# --------------------------------------------------------------------------- +# Серверное хранилище настроек по user_id. +# В памяти контейнера — переживает F5 в браузере, сбрасывается при рестарте. +# --------------------------------------------------------------------------- +_user_prefs: dict[str, dict] = {} # user_id -> {"theme": str, "scale": float} + + +def _get_uid(page: ft.Page) -> str: + return page.session.store.get("user_id") or "" + + +def _prefs_for(user_id: str) -> dict: + return _user_prefs.get(user_id, {"theme": "default", "scale": SCALE_DEFAULT}) + + +# --------------------------------------------------------------------------- +# Публичный API +# --------------------------------------------------------------------------- + +def apply_theme(page: ft.Page, theme_id: str | None = None) -> None: + """Применить тему: обновить ds.colors и page.bgcolor.""" + if theme_id is None: + uid = _get_uid(page) + theme_id = _prefs_for(uid)["theme"] if uid else "default" + if theme_id not in THEMES: + theme_id = "default" + + t = THEMES[theme_id] + ds.colors.background = t["background"] + ds.colors.surface = t["surface"] + ds.colors.surface_alt = t["surface_alt"] + + page.bgcolor = t["background"] + # кешируем в session.store для быстрого доступа внутри сессии + page.session.store.set(_THEME_KEY, theme_id) + + +def apply_scale(page: ft.Page, scale: float | None = None) -> None: + """Применить масштаб: обновить ds.SCALE. + + Если масштаб не задан явно и у пользователя нет сохранённого — + определяется автоматически по ширине экрана (мобильный / ПК). + """ + if scale is None: + uid = _get_uid(page) + if uid and uid in _user_prefs: + # есть явно сохранённая настройка пользователя + scale = _user_prefs[uid]["scale"] + else: + # первый вход — автоопределение по ширине экрана + scale = auto_scale(page) + scale = max(SCALE_MIN, min(SCALE_MAX, float(scale))) + ds.SCALE = scale + page.session.store.set(_SCALE_KEY, str(scale)) + + +def save_preferences(page: ft.Page, theme_id: str, scale: float, + user_id: str | None = None) -> None: + """Сохранить тему и масштаб. Хранятся в памяти сервера по user_id.""" + uid = user_id or _get_uid(page) + if uid: + _user_prefs[uid] = {"theme": theme_id, "scale": float(scale)} + # также пишем в session.store — нужно для apply_* внутри текущей сессии + page.session.store.set(_THEME_KEY, theme_id) + page.session.store.set(_SCALE_KEY, str(scale)) + + +def load_theme_id(page: ft.Page) -> str: + # сначала session.store (уже применено), потом serv dict + v = page.session.store.get(_THEME_KEY) + if v and v in THEMES: + return v + uid = _get_uid(page) + v2 = _prefs_for(uid)["theme"] if uid else "default" + return v2 if v2 in THEMES else "default" + + +def load_scale(page: ft.Page) -> float: + v = page.session.store.get(_SCALE_KEY) + try: + if v is not None: + return float(v) + except ValueError: + pass + uid = _get_uid(page) + return _prefs_for(uid)["scale"] if uid else SCALE_DEFAULT + + +def theme_list() -> list[dict]: + return [{"id": k, "name": v["name"]} for k, v in THEMES.items()] + + +# --------------------------------------------------------------------------- +# Устаревший алиас (оставлен для совместимости с роутером) +# --------------------------------------------------------------------------- +class ThemeManager: + THEMES = THEMES + + @staticmethod + def get_theme_list(): + return theme_list() + + @staticmethod + def load_theme_preference(page): + return load_theme_id(page) + + @staticmethod + def save_theme_preference(page, theme_id): + save_preferences(page, theme_id, load_scale(page)) diff --git a/web/views/content_users/main_page.py b/web/views/content_users/main_page.py index b8dbc02..0192c03 100644 --- a/web/views/content_users/main_page.py +++ b/web/views/content_users/main_page.py @@ -44,7 +44,7 @@ class MainPageUser(ft.Column): ) ] ), - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface, on_click=self.take_test, ink=True, ), @@ -74,7 +74,7 @@ class MainPageUser(ft.Column): ) ] ), - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface, on_click=self.my_test, ink=True, ) diff --git a/web/views/content_users/my_tests_page.py b/web/views/content_users/my_tests_page.py index 3af14cb..d10b46e 100644 --- a/web/views/content_users/my_tests_page.py +++ b/web/views/content_users/my_tests_page.py @@ -7,7 +7,7 @@ import api_client class MyTestsPage(ft.Column): def __init__(self, page: ft.Page): self._page = page - self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1) + self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light) super().__init__( alignment=ft.MainAxisAlignment.START, @@ -58,7 +58,7 @@ class MyTestsPage(ft.Column): ft.Container( border_radius=ds.s(12), padding=ds.s(16), - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface, content=ft.Row( alignment=ft.MainAxisAlignment.SPACE_BETWEEN, controls=[ @@ -77,7 +77,7 @@ class MyTestsPage(ft.Column): ), ], ), - ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.clolors.ferst), + ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.colors.primary), ], ), on_click=lambda e, rid=r["id"]: self._page.go( diff --git a/web/views/content_users/poll_page.py b/web/views/content_users/poll_page.py index ffac9a6..8358c45 100644 --- a/web/views/content_users/poll_page.py +++ b/web/views/content_users/poll_page.py @@ -20,7 +20,7 @@ class PollPage(ft.Column): controls=[ ft.Row( alignment=ft.MainAxisAlignment.CENTER, - controls=[ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1)], + controls=[ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)], ) ], ) @@ -57,46 +57,54 @@ class PollPage(ft.Column): tight=True, spacing=6, controls=[ - ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.clolors.ferst, size=ds.s(16)), - ft.Text("Прервать тест", color=ds.clolors.ferst, size=ds.s(13)), + ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.colors.primary, size=ds.s(16)), + ft.Text("Прервать тест", color=ds.colors.primary, size=ds.s(13)), ], ), style=ft.ButtonStyle( - bgcolor=ds.clolors.laer3, - color=ds.clolors.ferst, + bgcolor=ds.colors.surface_alt, + color=ds.colors.primary, ), on_click=self._confirm_abort, ) counter_badge = ft.Container( - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface_alt, border_radius=ds.s(10), padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)), content=ft.Text( f"Вопрос {idx + 1} из {total}", - color=ds.clolors.ferst, + color=ds.colors.text_secondary, size=ds.s(14), weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, ), ) - nav_row = ft.Row( - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - controls=[prev_btn, counter_badge, stop_btn], + nav_row = ft.Column( + spacing=ds.s(6), + controls=[ + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[counter_badge], + ), + ft.Row( + wrap=True, + spacing=ds.s(8), + run_spacing=ds.s(8), + controls=[prev_btn, stop_btn], + ), + ], ) - # --- Блок вопроса (alignment на Container, не вложенный Column) --- + # --- Блок вопроса --- question_box = ft.Container( - expand=True, border_radius=ds.s(14), padding=ds.s(24), - bgcolor=ds.clolors.laer2, - alignment=ft.Alignment(0.0, 0.0), + bgcolor=ds.colors.surface, content=ft.Text( value=q.get("text", ""), - color=ds.clolors.ferst, + color=ds.colors.text_primary, size=ds.s(16), weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, @@ -115,19 +123,19 @@ class PollPage(ft.Column): return ft.Container( border_radius=ds.s(8), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(10)), - bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, + bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt, content=ft.Row( spacing=ds.s(10), vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[ ft.Icon( ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, - color=ds.clolors.ferst, + color=ds.colors.primary, size=ds.s(20), ), ft.Text( value=c["text"], - color=ds.clolors.ferst, + color=ds.colors.text_primary, size=ds.s(14), expand=True, no_wrap=False, @@ -147,7 +155,7 @@ class PollPage(ft.Column): self.controls = [ ft.Column( - expand=True, + scroll=ft.ScrollMode.AUTO, spacing=ds.s(12), controls=[ nav_row, @@ -178,14 +186,14 @@ class PollPage(ft.Column): finish_btn = ft.Button( content=ft.Text( "Завершить тест →", - color=ds.clolors.ferst, + color=ds.colors.text_on_primary, size=ds.s(16), weight=ft.FontWeight.BOLD, ), data="__finish__", style=ft.ButtonStyle( - bgcolor=ds.clolors.laer1, - color=ds.clolors.ferst, + bgcolor=ds.colors.primary, + color=ds.colors.text_on_primary, ), on_click=self._submit, ) @@ -227,7 +235,7 @@ class PollPage(ft.Column): ), ft.TextButton( content="Прервать", - style=ft.ButtonStyle(color=ds.clolors.laer1), + style=ft.ButtonStyle(color=ds.colors.secondary_light), on_click=_do_abort, ), ], @@ -250,7 +258,7 @@ class PollPage(ft.Column): self.controls = [ ft.Row( alignment=ft.MainAxisAlignment.CENTER, - controls=[ft.ProgressRing(width=ds.s(48), height=ds.s(48), color=ds.clolors.laer1)], + controls=[ft.ProgressRing(width=ds.s(48), height=ds.s(48), color=ds.colors.secondary_light)], ) ] self.update() @@ -272,7 +280,7 @@ class PollPage(ft.Column): alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, controls=[ - ft.Icon(ft.Icons.ERROR_OUTLINE, size=ds.s(48), color=ds.clolors.laer1), + ft.Icon(ft.Icons.ERROR_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light), ds.CastomText(text=error, size=14), ds.CastomButton( text="Попробовать снова", @@ -292,7 +300,7 @@ class PollPage(ft.Column): ) success_controls: list[ft.Control] = [ - ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=ds.s(48), color=ds.clolors.laer1), + ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light), ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD), ] @@ -339,7 +347,7 @@ class PollPage(ft.Column): self.controls = [ ft.Container( padding=ds.s(20), - bgcolor=ds.clolors.laer3, + bgcolor=ds.colors.accent_lime, border_radius=ds.s(16), expand=True, content=ft.Column( diff --git a/web/views/content_users/profile_page.py b/web/views/content_users/profile_page.py index 1e4806b..a2d8dfe 100644 --- a/web/views/content_users/profile_page.py +++ b/web/views/content_users/profile_page.py @@ -2,6 +2,7 @@ import asyncio import flet as ft import designer as ds import api_client +import theme_manager as tm class ProfilePage(ft.Column): @@ -31,21 +32,53 @@ class ProfilePage(ft.Column): hint_text="Загрузка...", ) + # ── Выбор темы ───────────────────────────────────────────────── + self._theme_dropdown = ds.CastomDropdown( + label="Тема интерфейса", + hint_text="Выберите тему...", + options=[ + ft.DropdownOption(key=t["id"], text=t["name"]) + for t in tm.theme_list() + ], + ) + self._theme_dropdown.on_select = self._on_theme_preview + + # ── Слайдер масштаба ─────────────────────────────────────────── + self._scale_value = ds.SCALE + self._scale_label = ft.Text( + value=f"Масштаб: {ds.SCALE:.1f}×", + size=ds.s(13), + color=ds.colors.text_secondary, + ) + self._scale_slider = ft.Slider( + min=tm.SCALE_MIN, + max=tm.SCALE_MAX, + divisions=round((tm.SCALE_MAX - tm.SCALE_MIN) / tm.SCALE_STEP), + value=ds.SCALE, + label="{value}×", + active_color=ds.colors.primary, + inactive_color=ds.colors.surface_alt, + thumb_color=ds.colors.primary, + on_change=self._on_scale_preview, + on_change_end=self._on_scale_apply, + ) + self._loading = ft.ProgressRing(visible=False, width=ds.s(24), height=ds.s(24)) self._status = ft.Text(value="", size=ds.s(14)) super().__init__( spacing=ds.s(16), controls=[ - ds.CastomText( - text="Редактировать профиль", - size=18, - weight=ft.FontWeight.BOLD, - ), + ds.CastomText("Редактировать профиль", size=18, weight=ft.FontWeight.BOLD), self._first_name, self._last_name, self._org_dropdown, self._group_dropdown, + ft.Divider(height=ds.s(8), color=ds.colors.border), + ds.CastomText("Внешний вид", size=16, weight=ft.FontWeight.BOLD), + self._theme_dropdown, + self._scale_label, + self._scale_slider, ft.Row( spacing=ds.s(12), controls=[ @@ -57,6 +90,8 @@ class ProfilePage(ft.Column): ], ) + # ── загрузка данных ───────────────────────────────────────────────────── + async def load(self): token = self._page.session.store.get("auth_token") or "" me, orgs, groups = await asyncio.gather( @@ -66,7 +101,7 @@ class ProfilePage(ft.Column): ) self._orgs = orgs or [] - _opt_style = ft.ButtonStyle(color=ds.clolors.ferst) + _opt_style = ft.ButtonStyle(color=ds.colors.text_primary) self._org_dropdown.options = [ ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) @@ -80,6 +115,13 @@ class ProfilePage(ft.Column): ] self._group_dropdown.hint_text = "Выберите группу..." + # устанавливаем текущие настройки внешнего вида + self._theme_dropdown.value = tm.load_theme_id(self._page) + saved_scale = tm.load_scale(self._page) + self._scale_slider.value = saved_scale + self._scale_value = saved_scale + self._scale_label.value = f"Масштаб: {saved_scale:.1f}×" + if me: self._first_name.value = me.get("first_name", "") self._last_name.value = me.get("last_name", "") @@ -88,6 +130,8 @@ class ProfilePage(ft.Column): self.update() + # ── обработчики ───────────────────────────────────────────────────────── + def _on_org_change(self, e): selected_id = e.control.value for o in self._orgs: @@ -96,6 +140,33 @@ class ProfilePage(ft.Column): break self.update() + def _on_theme_preview(self, e): + """Применить тему предварительно (только bgcolor страницы).""" + theme_id = e.control.value + if theme_id: + tm.apply_theme(self._page, theme_id) + self._page.update() + + def _on_scale_preview(self, e): + """Обновить подпись слайдера во время перетаскивания.""" + v = round(e.control.value, 1) + self._scale_value = v + self._scale_label.value = f"Масштаб: {v:.1f}×" + self._scale_label.update() + + def _on_scale_apply(self, e): + """Применить масштаб сразу при отпускании слайдера.""" + scale = round(e.control.value, 1) + self._scale_value = scale + theme_id = self._theme_dropdown.value or tm.load_theme_id(self._page) + tm.save_preferences(self._page, theme_id, scale) + tm.apply_scale(self._page, scale) + tm.apply_theme(self._page, theme_id) + url_key = self._page.session.store.get("url_key") or "" + self._page.go(f"/profile/{url_key}") + + # ── сохранение ────────────────────────────────────────────────────────── + async def _save(self, e): self._loading.visible = True self._status.value = "" @@ -104,8 +175,8 @@ class ProfilePage(ft.Column): token = self._page.session.store.get("auth_token") or "" user_id = self._page.session.store.get("user_id") or "" - fn = self._first_name.value.strip() if self._first_name.value else None - ln = self._last_name.value.strip() if self._last_name.value else None + fn = (self._first_name.value or "").strip() or None + ln = (self._last_name.value or "").strip() or None data, err = await api_client.update_user( user_id=user_id, @@ -117,15 +188,26 @@ class ProfilePage(ft.Column): ) self._loading.visible = False + if err: self._status.value = err self._status.color = "red" - else: - self._status.value = "Данные успешно сохранены!" - self._status.color = ds.clolors.ferst - # Обновляем сессию для корректного сохранения при прохождении тестов - if data: + self.update() + return + + # всё успешно — сохраняем тему и масштаб в localStorage + theme_id = self._theme_dropdown.value or "default" + scale = self._scale_value + tm.save_preferences(self._page, theme_id, scale) + tm.apply_scale(self._page, scale) + tm.apply_theme(self._page, theme_id) + + if data: self._page.session.store.set("group_id", data.get("group_id") or "") self._page.session.store.set("org_id", data.get("organization_id") or "") - self.update() + + # перезагружаем текущую страницу — пересоздаёт все виджеты + # с новым SCALE и новыми цветами + url_key = self._page.session.store.get("url_key") or "" + self._page.go(f"/profile/{url_key}") diff --git a/web/views/content_users/response_page.py b/web/views/content_users/response_page.py index 2c8dc74..3f0aad8 100644 --- a/web/views/content_users/response_page.py +++ b/web/views/content_users/response_page.py @@ -8,7 +8,7 @@ class ResponsePage(ft.Column): def __init__(self, page: ft.Page, response_id: str): self._page = page self._response_id = response_id - self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1) + self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light) super().__init__( alignment=ft.MainAxisAlignment.START, @@ -63,7 +63,7 @@ class ResponsePage(ft.Column): controls.append(ft.Container( border_radius=ds.s(16), padding=ds.s(12), - bgcolor=ds.clolors.laer3, + bgcolor=ds.colors.surface_alt, content=ft.Column( horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=ds.s(10), @@ -105,11 +105,11 @@ class ResponsePage(ft.Column): choice_rows.append(ft.Container( border_radius=ds.s(8), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(6)), - bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, + bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt, content=ft.Row(spacing=ds.s(8), controls=[ ft.Icon( ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, - color=ds.clolors.ferst, size=ds.s(16), + color=ds.colors.primary, size=ds.s(16), ), ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)), ]), @@ -118,7 +118,7 @@ class ResponsePage(ft.Column): answer_cards.append(ft.Container( border_radius=ds.s(12), padding=ds.s(16), - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface, content=ft.Column(spacing=ds.s(8), controls=[ ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD), *choice_rows, diff --git a/web/views/content_users/take_test_page.py b/web/views/content_users/take_test_page.py index acce7fc..53064af 100644 --- a/web/views/content_users/take_test_page.py +++ b/web/views/content_users/take_test_page.py @@ -6,7 +6,7 @@ import api_client class TakeTestPage(ft.Column): def __init__(self, page: ft.Page): self._page = page - self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1) + self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light) super().__init__( alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, @@ -29,7 +29,7 @@ class TakeTestPage(ft.Column): ft.Container( border_radius=ds.s(12), padding=ds.s(16), - bgcolor=ds.clolors.laer2, + bgcolor=ds.colors.surface, content=ft.Column( spacing=ds.s(6), controls=[ diff --git a/web/views/login_view.py b/web/views/login_view.py index 5af85ed..96809ea 100644 --- a/web/views/login_view.py +++ b/web/views/login_view.py @@ -30,28 +30,28 @@ class LoginView(ft.View): ) self._reg_link = ft.TextButton( - content=ft.Text("Нет аккаунта? Зарегистрироваться", color=ds.clolors.ferst, size=ds.s(14)), + content=ft.Text("Нет аккаунта? Зарегистрироваться", color=ds.colors.primary, size=ds.s(14)), on_click=self.open_dialog, ) super().__init__( route="/login", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.CENTER, padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)), scroll=ft.ScrollMode.AUTO, ) self.controls = [ - ft.Text("ЦОПП", color=ds.clolors.ferst, size=ds.s(30), weight=ft.FontWeight.BOLD, + ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER), ft.Container(height=ds.s(8)), - ft.Text("Это проект цопп по проведению иследовательских тестирований", color=ds.clolors.ferst, size=ds.s(16), + ft.Text("Это проект цопп по проведению иследовательских тестирований", color=ds.colors.primary, size=ds.s(16), text_align=ft.TextAlign.CENTER), ft.Container(height=ds.s(24)), ft.Container( padding=ft.padding.all(ds.s(24)), - bgcolor=ds.clolors.laer3, + bgcolor=ds.colors.surface_alt, border_radius=ds.s(16), alignment=ft.Alignment.CENTER, content=ft.Column( diff --git a/web/views/main_view.py b/web/views/main_view.py index c9678a6..40c6e5b 100644 --- a/web/views/main_view.py +++ b/web/views/main_view.py @@ -12,13 +12,13 @@ class MainView(ft.View): super().__init__( route="/main", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.CENTER, vertical_alignment=ft.MainAxisAlignment.CENTER, ) self.controls = [ - ft.Text("ЦОПП",color=ds.clolors.ferst, size=ds.s(30)), - ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.clolors.ferst, size=ds.s(16)), + ft.Text("ЦОПП",color=ds.colors.primary, size=ds.s(30)), + ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.colors.primary, size=ds.s(16)), ft.Container(width=0, height=ds.s(20)), self.button_temp ] diff --git a/web/views/my_tests_view.py b/web/views/my_tests_view.py index 4945deb..0a91508 100644 --- a/web/views/my_tests_view.py +++ b/web/views/my_tests_view.py @@ -9,7 +9,7 @@ class MyTestsView(ft.View): back_btn = ft.IconButton( icon=ft.Icons.ARROW_BACK, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, on_click=lambda _: page.go(f"/user/{url_key}"), ) @@ -17,7 +17,7 @@ class MyTestsView(ft.View): super().__init__( route=f"/my_tests/{url_key}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, scroll=ft.ScrollMode.AUTO, @@ -33,6 +33,7 @@ class MyTestsView(ft.View): text="Мои тесты", size=22, weight=ft.FontWeight.BOLD, + expand=True, ), ] ), diff --git a/web/views/poll_view.py b/web/views/poll_view.py index 0dacd41..82bc130 100644 --- a/web/views/poll_view.py +++ b/web/views/poll_view.py @@ -9,7 +9,7 @@ class PollView(ft.View): back_btn = ft.IconButton( icon=ft.Icons.ARROW_BACK, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, on_click=lambda _: page.go(f"/take_test/{url_key}"), ) @@ -17,13 +17,13 @@ class PollView(ft.View): super().__init__( route=f"/poll/{url_key}/{poll_id}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)), horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, controls=[ ft.Column( - expand=True, spacing=ds.s(10), controls=[ ft.Row( @@ -33,6 +33,7 @@ class PollView(ft.View): text="Прохождение теста", size=22, weight=ft.FontWeight.BOLD, + expand=True, ), ] ), diff --git a/web/views/profile_view.py b/web/views/profile_view.py index 7244773..40a26ec 100644 --- a/web/views/profile_view.py +++ b/web/views/profile_view.py @@ -9,7 +9,7 @@ class ProfileView(ft.View): back_btn = ft.IconButton( icon=ft.Icons.ARROW_BACK, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, on_click=lambda _: page.go(f"/user/{url_key}"), ) @@ -17,7 +17,7 @@ class ProfileView(ft.View): super().__init__( route=f"/profile/{url_key}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, scroll=ft.ScrollMode.AUTO, @@ -33,6 +33,7 @@ class ProfileView(ft.View): text="Профиль", size=22, weight=ft.FontWeight.BOLD, + expand=True, ), ] ), diff --git a/web/views/reg_view.py b/web/views/reg_view.py index c292545..efaa5d3 100644 --- a/web/views/reg_view.py +++ b/web/views/reg_view.py @@ -60,22 +60,22 @@ class RegView(ft.View): super().__init__( route="/reg", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.CENTER, padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)), scroll=ft.ScrollMode.AUTO, ) self.controls = [ - ft.Text("COPP", color=ds.clolors.ferst, size=ds.s(30), weight=ft.FontWeight.BOLD, + ft.Text("COPP", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER), ft.Container(height=ds.s(8)), - ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=ds.s(16), + ft.Text("Регистрация в системе", color=ds.colors.primary, size=ds.s(16), text_align=ft.TextAlign.CENTER), ft.Container(height=ds.s(24)), ft.Container( padding=ft.padding.all(ds.s(24)), - bgcolor=ds.clolors.laer3, + bgcolor=ds.colors.surface_alt, border_radius=ds.s(16), content=ft.Column( spacing=ds.s(16), @@ -111,7 +111,7 @@ class RegView(ft.View): ) self._orgs = orgs or [] - _opt_style = ft.ButtonStyle(color=ds.clolors.ferst) + _opt_style = ft.ButtonStyle(color=ds.colors.text_primary) self._org_dropdown.options = [ ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs ] diff --git a/web/views/response_view.py b/web/views/response_view.py index 0495e89..489e021 100644 --- a/web/views/response_view.py +++ b/web/views/response_view.py @@ -9,7 +9,7 @@ class ResponseView(ft.View): back_btn = ft.IconButton( icon=ft.Icons.ARROW_BACK, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, on_click=lambda _: page.go(f"/my_tests/{url_key}"), ) @@ -17,7 +17,7 @@ class ResponseView(ft.View): super().__init__( route=f"/response/{url_key}/{response_id}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)), horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, @@ -34,6 +34,7 @@ class ResponseView(ft.View): text="Результаты теста", size=22, weight=ft.FontWeight.BOLD, + expand=True, ), ] ), diff --git a/web/views/take_test_view.py b/web/views/take_test_view.py index d942267..3f12f86 100644 --- a/web/views/take_test_view.py +++ b/web/views/take_test_view.py @@ -9,7 +9,7 @@ class TakeTestView(ft.View): back_btn = ft.IconButton( icon=ft.Icons.ARROW_BACK, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, on_click=lambda _: page.go(f"/user/{url_key}"), ) @@ -17,13 +17,14 @@ class TakeTestView(ft.View): super().__init__( route=f"/take_test/{url_key}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)), horizontal_alignment=ft.CrossAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START, + scroll=ft.ScrollMode.AUTO, controls=[ ft.Column( - expand=True, + spacing=ds.s(10), controls=[ ft.Row( controls=[ @@ -32,6 +33,7 @@ class TakeTestView(ft.View): text="Все тесты", size=22, weight=ft.FontWeight.BOLD, + expand=True, ), ] ), diff --git a/web/views/user_view.py b/web/views/user_view.py index 69aee7a..3a4623e 100644 --- a/web/views/user_view.py +++ b/web/views/user_view.py @@ -11,7 +11,7 @@ class UserView(ft.View): profile_btn = ft.IconButton( icon=ft.Icons.ACCOUNT_CIRCLE, - icon_color=ds.clolors.ferst, + icon_color=ds.colors.primary, icon_size=ds.s(30), tooltip="Профиль", on_click=lambda _: page.go(f"/profile/{url_key}"), @@ -19,22 +19,22 @@ class UserView(ft.View): super().__init__( route=f"/user/{url_key}", - bgcolor=ds.clolors.background, + bgcolor=ds.colors.background, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)), + scroll=ft.ScrollMode.AUTO, ) self.controls = [ ft.Column( - expand=True, - alignment=ft.MainAxisAlignment.START, + spacing=ds.s(12), controls=[ ft.Row( alignment=ft.MainAxisAlignment.SPACE_BETWEEN, controls=[ ft.Text( "Добро пожаловать в COPP!", - color=ds.clolors.ferst, + color=ds.colors.primary, size=ds.s(20), weight=ft.FontWeight.BOLD, expand=True,