This commit is contained in:
2026-04-06 15:40:06 +05:00
parent 10fb240dcd
commit 7435992e15
21 changed files with 993 additions and 150 deletions

506
README.md
View File

@@ -1,6 +1,510 @@
# api-copp # 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: <ADMIN_KEY>"
# Применить миграции (добавляет колонки, если БД уже была создана ранее)
curl -X POST http://localhost:8000/db/migrate \
-H "X-Admin-Key: <ADMIN_KEY>"
```
### 4. Загрузка теста Голланда
```bash
# Создать тест (возвращает poll_id)
curl -X POST http://localhost:8000/polls/holland \
-H "X-Admin-Key: <ADMIN_KEY>"
# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов)
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores \
-H "X-Admin-Key: <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 <token>`. 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://<server-ip>: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-файлах обновлены.
---
--- ---

View File

@@ -1,7 +1,8 @@
import flet as ft import flet as ft
# Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%. # Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%.
# Меняйте только эту константу, чтобы масштабировать весь UI. # Меняйте только эту переменную (или через theme_manager.apply_scale),
# чтобы масштабировать весь UI.
SCALE: float = 1.7 SCALE: float = 1.7
@@ -10,15 +11,44 @@ def s(v: int | float) -> int:
return round(v * SCALE) return round(v * SCALE)
class clolors: class colors:
background = "#C0DE9E" # Основная палитра в стиле copp74.ru
laer1 = "#FFEAB7" primary = "#F85A40" # Основной оранжево-красный (логотип ЦОПП)
laer2 = "#D9F1F4" primary_dark = "#D94530" # Тёмный для hover/активных состояний
laer3 = "#D2EA32" primary_light = "#FFEAB7" # Светлый персиковый (вторичный фон)
ferst = "#85C446"
# Фоны
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): 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__( super().__init__(
value=text, value=text,
color=color, color=color,
@@ -33,25 +63,26 @@ class CastomTextField_input(ft.TextField):
super().__init__( super().__init__(
label=label, label=label,
hint_text=hint_text, hint_text=hint_text,
filled=True, # заливка фона filled=True,
bgcolor=clolors.ferst, # цвет заливки bgcolor=colors.surface,
border_color=clolors.laer2, # цвет границы border_color=colors.border,
border_radius=s(12), # скругление углов focused_border_color=colors.primary,
border_radius=s(10),
label_style=ft.TextStyle( label_style=ft.TextStyle(
color=clolors.laer3, color=colors.text_secondary,
size=s(14), size=s(13),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.W_500,
), ),
hint_style=ft.TextStyle( hint_style=ft.TextStyle(
color=clolors.laer1, color=colors.border,
size=s(14), size=s(14),
italic=True, italic=True,
), ),
text_style=ft.TextStyle( text_style=ft.TextStyle(
color=clolors.laer3, color=colors.text_primary,
size=s(16), 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, prefix_icon=prefix_icon,
suffix_icon=suffix_icon, suffix_icon=suffix_icon,
on_change=on_change, on_change=on_change,
@@ -62,14 +93,20 @@ class CastomTextField_input(ft.TextField):
class CastomButton(ft.Button): class CastomButton(ft.Button):
def __init__(self, text, on_click): def __init__(self, text, on_click):
super().__init__( 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( style=ft.ButtonStyle(
color=clolors.ferst, color=colors.text_on_primary,
bgcolor=clolors.laer1, bgcolor={
text_style=ft.TextStyle( ft.ControlState.DEFAULT: colors.primary,
size=s(14) ft.ControlState.HOVERED: colors.primary_dark,
), },
padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)), shape=ft.RoundedRectangleBorder(radius=s(10)),
padding=ft.padding.symmetric(horizontal=s(20), vertical=s(12)),
), ),
on_click=on_click on_click=on_click
) )
@@ -83,15 +120,17 @@ class CastomIconButton(ft.Button):
spacing=s(8), spacing=s(8),
tight=True, tight=True,
controls=[ controls=[
ft.Icon(icon, color=clolors.ferst, size=s(18)), ft.Icon(icon, color=colors.text_on_primary, size=s(18)),
ft.Text(text, color=clolors.ferst, size=s(14)), ft.Text(text, color=colors.text_on_primary, size=s(14), weight=ft.FontWeight.W_600),
], ],
), ),
style=ft.ButtonStyle( style=ft.ButtonStyle(
color=clolors.ferst, bgcolor={
bgcolor=clolors.laer2, ft.ControlState.DEFAULT: colors.info,
text_style=ft.TextStyle(size=s(14)), ft.ControlState.HOVERED: "#006AA3",
padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)), },
shape=ft.RoundedRectangleBorder(radius=s(10)),
padding=ft.padding.symmetric(horizontal=s(18), vertical=s(12)),
), ),
on_click=on_click, on_click=on_click,
) )
@@ -103,27 +142,27 @@ class CastomDropdown(ft.Dropdown):
hint_text=hint_text, hint_text=hint_text,
options=options or [], options=options or [],
filled=True, filled=True,
fill_color=clolors.laer3, fill_color=colors.surface,
color=clolors.ferst, color=colors.text_primary,
border_color=clolors.laer2, border_color=colors.border,
border_radius=s(12), border_radius=s(10),
focused_border_color=clolors.laer1, focused_border_color=colors.primary,
label_style=ft.TextStyle( label_style=ft.TextStyle(
color=clolors.ferst, color=colors.text_secondary,
size=s(14), size=s(13),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.W_500,
), ),
hint_style=ft.TextStyle( hint_style=ft.TextStyle(
color=clolors.laer1, color=colors.border,
size=s(14), size=s(14),
italic=True, italic=True,
), ),
text_style=ft.TextStyle( text_style=ft.TextStyle(
color=clolors.ferst, color=colors.text_primary,
size=s(16), size=s(15),
), ),
menu_style=ft.MenuStyle( menu_style=ft.MenuStyle(
bgcolor=clolors.laer3, bgcolor=colors.surface,
), ),
) )
if on_change is not None: if on_change is not None:
@@ -134,11 +173,11 @@ class CastomSwitch(ft.Switch):
def __init__(self, label, on_change): def __init__(self, label, on_change):
super().__init__( super().__init__(
label=label, label=label,
label_text_style=ft.TextStyle(size=s(12), color=clolors.ferst), label_text_style=ft.TextStyle(size=s(13), color=colors.text_primary),
active_color=clolors.ferst, active_color=colors.primary,
active_track_color=clolors.laer1, active_track_color=colors.primary_light,
inactive_thumb_color=clolors.laer2, inactive_thumb_color=colors.border,
inactive_track_color=clolors.laer3, inactive_track_color=colors.surface_alt,
on_change=on_change on_change=on_change
) )
@@ -148,7 +187,7 @@ class AlterDiaalogApproval(ft.AlertDialog):
self._on_accept = on_accept self._on_accept = on_accept
self.switch_approval = CastomSwitch( self.switch_approval = CastomSwitch(
label="Я согласен с условиями пользовательского соглашения", label="",
on_change=self.switch_changed on_change=self.switch_changed
) )
@@ -159,15 +198,14 @@ class AlterDiaalogApproval(ft.AlertDialog):
self.button_approval.disabled = True self.button_approval.disabled = True
super().__init__( super().__init__(
bgcolor=clolors.laer3, bgcolor=colors.surface,
title=ft.Text( title=ft.Text(
"Пользовательское соглашение", "Пользовательское соглашение",
color=clolors.ferst, color=colors.primary,
size=s(16), size=s(16),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
), ),
content=ft.Container( content=ft.Container(
width=s(350),
height=s(400), height=s(400),
content=ft.Column( content=ft.Column(
scroll=ft.ScrollMode.AUTO, scroll=ft.ScrollMode.AUTO,
@@ -293,11 +331,11 @@ class AlterDiaalogApproval(ft.AlertDialog):
""", """,
expand=True, expand=True,
md_style_sheet=ft.MarkdownStyleSheet( md_style_sheet=ft.MarkdownStyleSheet(
p_text_style=ft.TextStyle(color=clolors.ferst, size=s(12)), p_text_style=ft.TextStyle(color=colors.text_primary, size=s(12)),
h1_text_style=ft.TextStyle(color=clolors.ferst, size=s(18), weight=ft.FontWeight.BOLD), h1_text_style=ft.TextStyle(color=colors.primary, size=s(18), weight=ft.FontWeight.BOLD),
h2_text_style=ft.TextStyle(color=clolors.ferst, size=s(15), 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=clolors.laer1, size=s(13), 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=clolors.laer1), a_text_style=ft.TextStyle(color=colors.primary),
), ),
) )
] ]
@@ -305,16 +343,20 @@ class AlterDiaalogApproval(ft.AlertDialog):
), ),
actions=[ actions=[
ft.Column( ft.Column(
width=s(350),
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[ controls=[
ft.Row( ft.Row(
expand=True, expand=True,
alignment=ft.MainAxisAlignment.START, alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[ controls=[
ft.Container( self.switch_approval,
width=s(330), ft.Text(
content=self.switch_approval, "Я согласен с условиями пользовательского соглашения",
size=s(13),
color=colors.text_primary,
expand=True,
no_wrap=False,
), ),
] ]
), ),

View File

@@ -3,6 +3,7 @@ import flet as ft
import httpx import httpx
import designer as ds import designer as ds
from router import setup_router from router import setup_router
import theme_manager as tm
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.routing import Route, Mount from starlette.routing import Route, Mount
from starlette.responses import Response as StarletteResponse from starlette.responses import Response as StarletteResponse
@@ -29,7 +30,11 @@ async def _radar_image(request):
async def main(page: ft.Page): async def main(page: ft.Page):
page.title = "COPP" page.title = "COPP"
page.bgcolor = ds.clolors.background
# Применяем сохранённые тему и масштаб до построения любых виджетов
tm.apply_scale(page)
tm.apply_theme(page)
# Масштабирование глобального text_theme синхронно с ds.SCALE: # Масштабирование глобального text_theme синхронно с ds.SCALE:
# покрывает ft.Text() без явного size (body_medium по умолчанию = 14) # покрывает ft.Text() без явного size (body_medium по умолчанию = 14)
_ts = lambda base: ft.TextStyle(size=ds.s(base)) _ts = lambda base: ft.TextStyle(size=ds.s(base))

View File

@@ -95,7 +95,7 @@ class RadarChart(ft.Stack):
a = math.radians(-90.0 + 360.0 * i / n) a = math.radians(-90.0 + 360.0 * i / n)
lx = ccx + _LBL_R * math.cos(a) lx = ccx + _LBL_R * math.cos(a)
ly = ccy + _LBL_R * math.sin(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( labels.append(ft.Container(
left=lx - _W_LBL / 2, left=lx - _W_LBL / 2,
@@ -119,7 +119,7 @@ class RadarChart(ft.Stack):
ft.Text( ft.Text(
str(int(item["value"])), str(int(item["value"])),
size=11, size=11,
color=ds.clolors.ferst, color=ds.colors.primary,
text_align=ft.TextAlign.CENTER, text_align=ft.TextAlign.CENTER,
), ),
], ],

View File

@@ -1,5 +1,6 @@
import flet as ft import flet as ft
from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES
import theme_manager as tm
async def _redirect_to_login(page: ft.Page): 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) views = resolve_view(page, route)
# Применяем сохранённые настройки пользователя перед построением виджетов
tm.apply_scale(page)
tm.apply_theme(page)
# Неизвестный маршрут → на страницу пользователя # Неизвестный маршрут → на страницу пользователя
if views is None: if views is None:
await page.push_route(f"/user/{url_key}") 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): async def _on_route_change(e: ft.RouteChangeEvent):
await _handle_route_change(page, e) 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_route_change = _on_route_change
page.on_resize = _on_resize

178
web/theme_manager.py Normal file
View File

@@ -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))

View File

@@ -44,7 +44,7 @@ class MainPageUser(ft.Column):
) )
] ]
), ),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
on_click=self.take_test, on_click=self.take_test,
ink=True, ink=True,
), ),
@@ -74,7 +74,7 @@ class MainPageUser(ft.Column):
) )
] ]
), ),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
on_click=self.my_test, on_click=self.my_test,
ink=True, ink=True,
) )

View File

@@ -7,7 +7,7 @@ import api_client
class MyTestsPage(ft.Column): class MyTestsPage(ft.Column):
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self._page = 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__( super().__init__(
alignment=ft.MainAxisAlignment.START, alignment=ft.MainAxisAlignment.START,
@@ -58,7 +58,7 @@ class MyTestsPage(ft.Column):
ft.Container( ft.Container(
border_radius=ds.s(12), border_radius=ds.s(12),
padding=ds.s(16), padding=ds.s(16),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
content=ft.Row( content=ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN, alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[ 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( on_click=lambda e, rid=r["id"]: self._page.go(

View File

@@ -20,7 +20,7 @@ class PollPage(ft.Column):
controls=[ controls=[
ft.Row( ft.Row(
alignment=ft.MainAxisAlignment.CENTER, 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, tight=True,
spacing=6, spacing=6,
controls=[ controls=[
ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.clolors.ferst, size=ds.s(16)), ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.colors.primary, size=ds.s(16)),
ft.Text("Прервать тест", color=ds.clolors.ferst, size=ds.s(13)), ft.Text("Прервать тест", color=ds.colors.primary, size=ds.s(13)),
], ],
), ),
style=ft.ButtonStyle( style=ft.ButtonStyle(
bgcolor=ds.clolors.laer3, bgcolor=ds.colors.surface_alt,
color=ds.clolors.ferst, color=ds.colors.primary,
), ),
on_click=self._confirm_abort, on_click=self._confirm_abort,
) )
counter_badge = ft.Container( counter_badge = ft.Container(
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface_alt,
border_radius=ds.s(10), border_radius=ds.s(10),
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)), padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)),
content=ft.Text( content=ft.Text(
f"Вопрос {idx + 1} из {total}", f"Вопрос {idx + 1} из {total}",
color=ds.clolors.ferst, color=ds.colors.text_secondary,
size=ds.s(14), size=ds.s(14),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER, text_align=ft.TextAlign.CENTER,
), ),
) )
nav_row = ft.Row( nav_row = ft.Column(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=ds.s(6),
vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[
controls=[prev_btn, counter_badge, stop_btn], 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( question_box = ft.Container(
expand=True,
border_radius=ds.s(14), border_radius=ds.s(14),
padding=ds.s(24), padding=ds.s(24),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
alignment=ft.Alignment(0.0, 0.0),
content=ft.Text( content=ft.Text(
value=q.get("text", ""), value=q.get("text", ""),
color=ds.clolors.ferst, color=ds.colors.text_primary,
size=ds.s(16), size=ds.s(16),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER, text_align=ft.TextAlign.CENTER,
@@ -115,19 +123,19 @@ class PollPage(ft.Column):
return ft.Container( return ft.Container(
border_radius=ds.s(8), border_radius=ds.s(8),
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(10)), 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( content=ft.Row(
spacing=ds.s(10), spacing=ds.s(10),
vertical_alignment=ft.CrossAxisAlignment.CENTER, vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[ controls=[
ft.Icon( ft.Icon(
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, 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), size=ds.s(20),
), ),
ft.Text( ft.Text(
value=c["text"], value=c["text"],
color=ds.clolors.ferst, color=ds.colors.text_primary,
size=ds.s(14), size=ds.s(14),
expand=True, expand=True,
no_wrap=False, no_wrap=False,
@@ -147,7 +155,7 @@ class PollPage(ft.Column):
self.controls = [ self.controls = [
ft.Column( ft.Column(
expand=True, scroll=ft.ScrollMode.AUTO,
spacing=ds.s(12), spacing=ds.s(12),
controls=[ controls=[
nav_row, nav_row,
@@ -178,14 +186,14 @@ class PollPage(ft.Column):
finish_btn = ft.Button( finish_btn = ft.Button(
content=ft.Text( content=ft.Text(
"Завершить тест →", "Завершить тест →",
color=ds.clolors.ferst, color=ds.colors.text_on_primary,
size=ds.s(16), size=ds.s(16),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
), ),
data="__finish__", data="__finish__",
style=ft.ButtonStyle( style=ft.ButtonStyle(
bgcolor=ds.clolors.laer1, bgcolor=ds.colors.primary,
color=ds.clolors.ferst, color=ds.colors.text_on_primary,
), ),
on_click=self._submit, on_click=self._submit,
) )
@@ -227,7 +235,7 @@ class PollPage(ft.Column):
), ),
ft.TextButton( ft.TextButton(
content="Прервать", content="Прервать",
style=ft.ButtonStyle(color=ds.clolors.laer1), style=ft.ButtonStyle(color=ds.colors.secondary_light),
on_click=_do_abort, on_click=_do_abort,
), ),
], ],
@@ -250,7 +258,7 @@ class PollPage(ft.Column):
self.controls = [ self.controls = [
ft.Row( ft.Row(
alignment=ft.MainAxisAlignment.CENTER, 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() self.update()
@@ -272,7 +280,7 @@ class PollPage(ft.Column):
alignment=ft.MainAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[ 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.CastomText(text=error, size=14),
ds.CastomButton( ds.CastomButton(
text="Попробовать снова", text="Попробовать снова",
@@ -292,7 +300,7 @@ class PollPage(ft.Column):
) )
success_controls: list[ft.Control] = [ 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), ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
] ]
@@ -339,7 +347,7 @@ class PollPage(ft.Column):
self.controls = [ self.controls = [
ft.Container( ft.Container(
padding=ds.s(20), padding=ds.s(20),
bgcolor=ds.clolors.laer3, bgcolor=ds.colors.accent_lime,
border_radius=ds.s(16), border_radius=ds.s(16),
expand=True, expand=True,
content=ft.Column( content=ft.Column(

View File

@@ -2,6 +2,7 @@ import asyncio
import flet as ft import flet as ft
import designer as ds import designer as ds
import api_client import api_client
import theme_manager as tm
class ProfilePage(ft.Column): class ProfilePage(ft.Column):
@@ -31,21 +32,53 @@ class ProfilePage(ft.Column):
hint_text="Загрузка...", 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._loading = ft.ProgressRing(visible=False, width=ds.s(24), height=ds.s(24))
self._status = ft.Text(value="", size=ds.s(14)) self._status = ft.Text(value="", size=ds.s(14))
super().__init__( super().__init__(
spacing=ds.s(16), spacing=ds.s(16),
controls=[ controls=[
ds.CastomText( ds.CastomText("Редактировать профиль", size=18, weight=ft.FontWeight.BOLD),
text="Редактировать профиль",
size=18,
weight=ft.FontWeight.BOLD,
),
self._first_name, self._first_name,
self._last_name, self._last_name,
self._org_dropdown, self._org_dropdown,
self._group_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( ft.Row(
spacing=ds.s(12), spacing=ds.s(12),
controls=[ controls=[
@@ -57,6 +90,8 @@ class ProfilePage(ft.Column):
], ],
) )
# ── загрузка данных ─────────────────────────────────────────────────────
async def load(self): async def load(self):
token = self._page.session.store.get("auth_token") or "" token = self._page.session.store.get("auth_token") or ""
me, orgs, groups = await asyncio.gather( me, orgs, groups = await asyncio.gather(
@@ -66,7 +101,7 @@ class ProfilePage(ft.Column):
) )
self._orgs = orgs or [] 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 = [ self._org_dropdown.options = [
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) 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._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: if me:
self._first_name.value = me.get("first_name", "") self._first_name.value = me.get("first_name", "")
self._last_name.value = me.get("last_name", "") self._last_name.value = me.get("last_name", "")
@@ -88,6 +130,8 @@ class ProfilePage(ft.Column):
self.update() self.update()
# ── обработчики ─────────────────────────────────────────────────────────
def _on_org_change(self, e): def _on_org_change(self, e):
selected_id = e.control.value selected_id = e.control.value
for o in self._orgs: for o in self._orgs:
@@ -96,6 +140,33 @@ class ProfilePage(ft.Column):
break break
self.update() 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): async def _save(self, e):
self._loading.visible = True self._loading.visible = True
self._status.value = "" self._status.value = ""
@@ -104,8 +175,8 @@ class ProfilePage(ft.Column):
token = self._page.session.store.get("auth_token") or "" token = self._page.session.store.get("auth_token") or ""
user_id = self._page.session.store.get("user_id") or "" user_id = self._page.session.store.get("user_id") or ""
fn = self._first_name.value.strip() if self._first_name.value else None fn = (self._first_name.value or "").strip() or None
ln = self._last_name.value.strip() if self._last_name.value else None ln = (self._last_name.value or "").strip() or None
data, err = await api_client.update_user( data, err = await api_client.update_user(
user_id=user_id, user_id=user_id,
@@ -117,15 +188,26 @@ class ProfilePage(ft.Column):
) )
self._loading.visible = False self._loading.visible = False
if err: if err:
self._status.value = err self._status.value = err
self._status.color = "red" self._status.color = "red"
else: self.update()
self._status.value = "Данные успешно сохранены!" return
self._status.color = ds.clolors.ferst
# Обновляем сессию для корректного сохранения при прохождении тестов # всё успешно — сохраняем тему и масштаб в localStorage
if data: 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("group_id", data.get("group_id") or "")
self._page.session.store.set("org_id", data.get("organization_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}")

View File

@@ -8,7 +8,7 @@ class ResponsePage(ft.Column):
def __init__(self, page: ft.Page, response_id: str): def __init__(self, page: ft.Page, response_id: str):
self._page = page self._page = page
self._response_id = response_id 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__( super().__init__(
alignment=ft.MainAxisAlignment.START, alignment=ft.MainAxisAlignment.START,
@@ -63,7 +63,7 @@ class ResponsePage(ft.Column):
controls.append(ft.Container( controls.append(ft.Container(
border_radius=ds.s(16), border_radius=ds.s(16),
padding=ds.s(12), padding=ds.s(12),
bgcolor=ds.clolors.laer3, bgcolor=ds.colors.surface_alt,
content=ft.Column( content=ft.Column(
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=ds.s(10), spacing=ds.s(10),
@@ -105,11 +105,11 @@ class ResponsePage(ft.Column):
choice_rows.append(ft.Container( choice_rows.append(ft.Container(
border_radius=ds.s(8), border_radius=ds.s(8),
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(6)), 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=[ content=ft.Row(spacing=ds.s(8), controls=[
ft.Icon( ft.Icon(
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, 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)), 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( answer_cards.append(ft.Container(
border_radius=ds.s(12), border_radius=ds.s(12),
padding=ds.s(16), padding=ds.s(16),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
content=ft.Column(spacing=ds.s(8), controls=[ content=ft.Column(spacing=ds.s(8), controls=[
ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD), ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
*choice_rows, *choice_rows,

View File

@@ -6,7 +6,7 @@ import api_client
class TakeTestPage(ft.Column): class TakeTestPage(ft.Column):
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self._page = 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__( super().__init__(
alignment=ft.MainAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
@@ -29,7 +29,7 @@ class TakeTestPage(ft.Column):
ft.Container( ft.Container(
border_radius=ds.s(12), border_radius=ds.s(12),
padding=ds.s(16), padding=ds.s(16),
bgcolor=ds.clolors.laer2, bgcolor=ds.colors.surface,
content=ft.Column( content=ft.Column(
spacing=ds.s(6), spacing=ds.s(6),
controls=[ controls=[

View File

@@ -30,28 +30,28 @@ class LoginView(ft.View):
) )
self._reg_link = ft.TextButton( 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, on_click=self.open_dialog,
) )
super().__init__( super().__init__(
route="/login", route="/login",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER, vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)), padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
scroll=ft.ScrollMode.AUTO, scroll=ft.ScrollMode.AUTO,
) )
self.controls = [ 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), text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(8)), 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), text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(24)), ft.Container(height=ds.s(24)),
ft.Container( ft.Container(
padding=ft.padding.all(ds.s(24)), padding=ft.padding.all(ds.s(24)),
bgcolor=ds.clolors.laer3, bgcolor=ds.colors.surface_alt,
border_radius=ds.s(16), border_radius=ds.s(16),
alignment=ft.Alignment.CENTER, alignment=ft.Alignment.CENTER,
content=ft.Column( content=ft.Column(

View File

@@ -12,13 +12,13 @@ class MainView(ft.View):
super().__init__( super().__init__(
route="/main", route="/main",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
vertical_alignment=ft.MainAxisAlignment.CENTER, vertical_alignment=ft.MainAxisAlignment.CENTER,
) )
self.controls = [ self.controls = [
ft.Text("ЦОПП",color=ds.clolors.ferst, size=ds.s(30)), ft.Text("ЦОПП",color=ds.colors.primary, size=ds.s(30)),
ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.clolors.ferst, size=ds.s(16)), ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.colors.primary, size=ds.s(16)),
ft.Container(width=0, height=ds.s(20)), ft.Container(width=0, height=ds.s(20)),
self.button_temp self.button_temp
] ]

View File

@@ -9,7 +9,7 @@ class MyTestsView(ft.View):
back_btn = ft.IconButton( back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK, icon=ft.Icons.ARROW_BACK,
icon_color=ds.clolors.ferst, icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"), on_click=lambda _: page.go(f"/user/{url_key}"),
) )
@@ -17,7 +17,7 @@ class MyTestsView(ft.View):
super().__init__( super().__init__(
route=f"/my_tests/{url_key}", route=f"/my_tests/{url_key}",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO, scroll=ft.ScrollMode.AUTO,
@@ -33,6 +33,7 @@ class MyTestsView(ft.View):
text="Мои тесты", text="Мои тесты",
size=22, size=22,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True,
), ),
] ]
), ),

View File

@@ -9,7 +9,7 @@ class PollView(ft.View):
back_btn = ft.IconButton( back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK, 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}"), on_click=lambda _: page.go(f"/take_test/{url_key}"),
) )
@@ -17,13 +17,13 @@ class PollView(ft.View):
super().__init__( super().__init__(
route=f"/poll/{url_key}/{poll_id}", 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)), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[ controls=[
ft.Column( ft.Column(
expand=True,
spacing=ds.s(10), spacing=ds.s(10),
controls=[ controls=[
ft.Row( ft.Row(
@@ -33,6 +33,7 @@ class PollView(ft.View):
text="Прохождение теста", text="Прохождение теста",
size=22, size=22,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True,
), ),
] ]
), ),

View File

@@ -9,7 +9,7 @@ class ProfileView(ft.View):
back_btn = ft.IconButton( back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK, icon=ft.Icons.ARROW_BACK,
icon_color=ds.clolors.ferst, icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"), on_click=lambda _: page.go(f"/user/{url_key}"),
) )
@@ -17,7 +17,7 @@ class ProfileView(ft.View):
super().__init__( super().__init__(
route=f"/profile/{url_key}", route=f"/profile/{url_key}",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO, scroll=ft.ScrollMode.AUTO,
@@ -33,6 +33,7 @@ class ProfileView(ft.View):
text="Профиль", text="Профиль",
size=22, size=22,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True,
), ),
] ]
), ),

View File

@@ -60,22 +60,22 @@ class RegView(ft.View):
super().__init__( super().__init__(
route="/reg", route="/reg",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.CENTER, vertical_alignment=ft.MainAxisAlignment.CENTER,
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)), padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
scroll=ft.ScrollMode.AUTO, scroll=ft.ScrollMode.AUTO,
) )
self.controls = [ 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), text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(8)), 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), text_align=ft.TextAlign.CENTER),
ft.Container(height=ds.s(24)), ft.Container(height=ds.s(24)),
ft.Container( ft.Container(
padding=ft.padding.all(ds.s(24)), padding=ft.padding.all(ds.s(24)),
bgcolor=ds.clolors.laer3, bgcolor=ds.colors.surface_alt,
border_radius=ds.s(16), border_radius=ds.s(16),
content=ft.Column( content=ft.Column(
spacing=ds.s(16), spacing=ds.s(16),
@@ -111,7 +111,7 @@ class RegView(ft.View):
) )
self._orgs = orgs or [] 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 = [ self._org_dropdown.options = [
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs
] ]

View File

@@ -9,7 +9,7 @@ class ResponseView(ft.View):
back_btn = ft.IconButton( back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK, 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}"), on_click=lambda _: page.go(f"/my_tests/{url_key}"),
) )
@@ -17,7 +17,7 @@ class ResponseView(ft.View):
super().__init__( super().__init__(
route=f"/response/{url_key}/{response_id}", 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)), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
@@ -34,6 +34,7 @@ class ResponseView(ft.View):
text="Результаты теста", text="Результаты теста",
size=22, size=22,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True,
), ),
] ]
), ),

View File

@@ -9,7 +9,7 @@ class TakeTestView(ft.View):
back_btn = ft.IconButton( back_btn = ft.IconButton(
icon=ft.Icons.ARROW_BACK, icon=ft.Icons.ARROW_BACK,
icon_color=ds.clolors.ferst, icon_color=ds.colors.primary,
on_click=lambda _: page.go(f"/user/{url_key}"), on_click=lambda _: page.go(f"/user/{url_key}"),
) )
@@ -17,13 +17,14 @@ class TakeTestView(ft.View):
super().__init__( super().__init__(
route=f"/take_test/{url_key}", 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)), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
horizontal_alignment=ft.CrossAxisAlignment.START, horizontal_alignment=ft.CrossAxisAlignment.START,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[ controls=[
ft.Column( ft.Column(
expand=True, spacing=ds.s(10),
controls=[ controls=[
ft.Row( ft.Row(
controls=[ controls=[
@@ -32,6 +33,7 @@ class TakeTestView(ft.View):
text="Все тесты", text="Все тесты",
size=22, size=22,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True,
), ),
] ]
), ),

View File

@@ -11,7 +11,7 @@ class UserView(ft.View):
profile_btn = ft.IconButton( profile_btn = ft.IconButton(
icon=ft.Icons.ACCOUNT_CIRCLE, icon=ft.Icons.ACCOUNT_CIRCLE,
icon_color=ds.clolors.ferst, icon_color=ds.colors.primary,
icon_size=ds.s(30), icon_size=ds.s(30),
tooltip="Профиль", tooltip="Профиль",
on_click=lambda _: page.go(f"/profile/{url_key}"), on_click=lambda _: page.go(f"/profile/{url_key}"),
@@ -19,22 +19,22 @@ class UserView(ft.View):
super().__init__( super().__init__(
route=f"/user/{url_key}", route=f"/user/{url_key}",
bgcolor=ds.clolors.background, bgcolor=ds.colors.background,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)), padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
scroll=ft.ScrollMode.AUTO,
) )
self.controls = [ self.controls = [
ft.Column( ft.Column(
expand=True, spacing=ds.s(12),
alignment=ft.MainAxisAlignment.START,
controls=[ controls=[
ft.Row( ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN, alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[ controls=[
ft.Text( ft.Text(
"Добро пожаловать в COPP!", "Добро пожаловать в COPP!",
color=ds.clolors.ferst, color=ds.colors.primary,
size=ds.s(20), size=ds.s(20),
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
expand=True, expand=True,