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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=[

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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