Added — 도전과제 시스템 (153개 자동 평가) - core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제 - ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿) - 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push - achievements 테이블 확장 (code/category/tier/is_secret/progress/target) - hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키 Changed — 다크 테마 디자인 리뉴얼 - ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress) - 통계/도움말/도전과제 다이얼로그 일관 다크 톤 - matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend) - 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드) Fixed — 안정성·일관성 - 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch) - DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환 - DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자) - crash_handler 다단계 폴백 (DB → 파일 → stderr) - updater PID race: 지수 backoff 재시도 (총 ~9초) - Discord URL 형식 검증 (snowflake regex) - 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증 - check_dinner_reminder 신규, 알림 임계값 5개 설정화 - closeEvent timer/notifier 정리 (aboutToQuit hook) - 마이그레이션 12개 모두 _conn() + try/finally - DB 인덱스 5개 추가 (break/overtime/leave date) Tests - pytest 116/116 PASS, 통합 시나리오 48/48 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""
|
|
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
|
|
|
핵심 원칙:
|
|
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
|
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
|
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
|
- 탭: 골드 강조 선택
|
|
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
|
|
|
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
|
"""
|
|
from __future__ import annotations
|
|
from typing import Optional, List, Tuple
|
|
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
|
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
|
QTabWidget)
|
|
from PyQt5.QtCore import Qt
|
|
|
|
|
|
# ── 색상 팔레트 ────────────────────────────────────────────────
|
|
DARK_BG = '#0e0e14'
|
|
DARK_PANEL = '#14141c'
|
|
DARK_PANEL_2 = '#1c1c28'
|
|
DARK_BORDER = '#2a2a3a'
|
|
DARK_BORDER_STRONG = '#44446a'
|
|
DARK_TEXT = '#e8e8f4'
|
|
DARK_TEXT_DIM = '#a0a0b8'
|
|
DARK_TEXT_FAINT = '#666680'
|
|
|
|
ACCENT_GOLD = '#ffd24a'
|
|
ACCENT_BLUE = '#6b9eff'
|
|
ACCENT_CYAN = '#4adef0'
|
|
ACCENT_PINK = '#ff90b8'
|
|
ACCENT_GREEN = '#4ade80'
|
|
ACCENT_ORANGE = '#fcd34d'
|
|
ACCENT_RED = '#fb7185'
|
|
|
|
# 카드 테마 (등급/상태별)
|
|
CARD_THEMES = {
|
|
'gold': {
|
|
'border': '#ffb700', 'border_strong': '#ffd24a',
|
|
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
|
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
|
},
|
|
'blue': {
|
|
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
|
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
|
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
|
},
|
|
'cyan': {
|
|
'border': '#3acce0', 'border_strong': '#4adef0',
|
|
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
|
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
|
},
|
|
'green': {
|
|
'border': '#3ace70', 'border_strong': '#4ade80',
|
|
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
|
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
|
},
|
|
'pink': {
|
|
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
|
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
|
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
|
},
|
|
'red': {
|
|
'border': '#ea5566', 'border_strong': '#fb7185',
|
|
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
|
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
|
},
|
|
'gray': {
|
|
'border': '#44446a', 'border_strong': '#666688',
|
|
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
|
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
|
},
|
|
}
|
|
|
|
|
|
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
|
|
|
def dialog_qss() -> str:
|
|
"""다이얼로그 전체 배경."""
|
|
return f"QDialog {{ background: {DARK_BG}; }}"
|
|
|
|
|
|
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
|
return f"""
|
|
QTabWidget::pane {{
|
|
background: {DARK_PANEL};
|
|
border: 1px solid {DARK_BORDER};
|
|
border-radius: 10px;
|
|
top: -1px;
|
|
}}
|
|
QTabBar::tab {{
|
|
background: {DARK_PANEL_2};
|
|
color: {DARK_TEXT_DIM};
|
|
padding: 9px 18px;
|
|
border: 1px solid {DARK_BORDER};
|
|
border-bottom: none;
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
margin-right: 3px;
|
|
font-size: 10pt;
|
|
}}
|
|
QTabBar::tab:selected {{
|
|
background: {DARK_PANEL};
|
|
color: {accent};
|
|
font-weight: bold;
|
|
border-bottom: 2px solid {accent};
|
|
}}
|
|
QTabBar::tab:hover:!selected {{
|
|
background: #2a2a36;
|
|
color: {DARK_TEXT};
|
|
}}
|
|
"""
|
|
|
|
|
|
def scroll_qss() -> str:
|
|
return f"""
|
|
QScrollArea {{ background: transparent; border: none; }}
|
|
QScrollBar:vertical {{
|
|
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
|
}}
|
|
QScrollBar::handle:vertical {{
|
|
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
|
}}
|
|
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
|
QScrollBar:horizontal {{
|
|
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
|
}}
|
|
QScrollBar::handle:horizontal {{
|
|
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
|
}}
|
|
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
|
"""
|
|
|
|
|
|
def button_qss(variant: str = 'default') -> str:
|
|
""" variant: default | primary | success | danger | ghost """
|
|
if variant == 'primary':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {ACCENT_BLUE}; color: white;
|
|
border: none; border-radius: 6px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: #82b0ff; }}
|
|
QPushButton:pressed {{ background: #5a8eee; }}
|
|
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
|
"""
|
|
if variant == 'success':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {ACCENT_GREEN}; color: #0e2a1a;
|
|
border: none; border-radius: 6px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: #6ae899; }}
|
|
"""
|
|
if variant == 'danger':
|
|
return f"""
|
|
QPushButton {{
|
|
background: {ACCENT_RED}; color: white;
|
|
border: none; border-radius: 6px;
|
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: #fc8896; }}
|
|
"""
|
|
if variant == 'ghost':
|
|
return f"""
|
|
QPushButton {{
|
|
background: transparent; color: {DARK_TEXT_DIM};
|
|
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
|
padding: 6px 14px; font-size: 9.5pt;
|
|
}}
|
|
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
|
border-color: {ACCENT_BLUE}; }}
|
|
"""
|
|
# default
|
|
return f"""
|
|
QPushButton {{
|
|
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
|
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
|
padding: 8px 18px; font-size: 10pt;
|
|
}}
|
|
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
|
"""
|
|
|
|
|
|
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
|
|
|
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
|
big_color: str = ACCENT_GOLD,
|
|
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
|
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
|
|
|
Args:
|
|
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
|
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
|
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
|
big_color: 큰 숫자 색
|
|
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
|
"""
|
|
container = QFrame()
|
|
container.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #1a1a30, stop:1 #2a1a3a);
|
|
border: 1px solid #3a3a5a;
|
|
border-radius: 12px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
|
""")
|
|
layout = QHBoxLayout()
|
|
layout.setContentsMargins(20, 14, 20, 14)
|
|
layout.setSpacing(20)
|
|
|
|
# 좌측: 제목 + 큰 숫자
|
|
left = QVBoxLayout()
|
|
left.setSpacing(2)
|
|
if title:
|
|
t = QLabel(title)
|
|
t.setStyleSheet(
|
|
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
left.addWidget(t)
|
|
big = QLabel(
|
|
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
|
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
|
f" {subtitle}</span>" if subtitle else '')
|
|
)
|
|
big.setTextFormat(Qt.RichText)
|
|
big.setStyleSheet("background: transparent; border: none;")
|
|
left.addWidget(big)
|
|
layout.addLayout(left)
|
|
|
|
# 우측: extra widgets
|
|
if extra_widgets:
|
|
layout.addStretch()
|
|
for w in extra_widgets:
|
|
layout.addWidget(w)
|
|
else:
|
|
layout.addStretch()
|
|
|
|
container.setLayout(layout)
|
|
return container
|
|
|
|
|
|
def build_stat_card(title: str, value: str, subtitle: str = '',
|
|
theme: str = 'blue', icon: str = '') -> QFrame:
|
|
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
|
card = QFrame()
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
|
border: 1px solid {t['border']};
|
|
border-radius: 10px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
|
""")
|
|
outer = QHBoxLayout()
|
|
outer.setContentsMargins(16, 12, 16, 12)
|
|
outer.setSpacing(12)
|
|
|
|
if icon:
|
|
icon_lbl = QLabel(icon)
|
|
icon_lbl.setStyleSheet(
|
|
f"font-size: 28pt; background: transparent; border: none; "
|
|
f"color: {t['border_strong']};"
|
|
)
|
|
icon_lbl.setMinimumWidth(48)
|
|
icon_lbl.setAlignment(Qt.AlignCenter)
|
|
outer.addWidget(icon_lbl)
|
|
|
|
text_box = QVBoxLayout()
|
|
text_box.setSpacing(2)
|
|
|
|
title_lbl = QLabel(title)
|
|
title_lbl.setStyleSheet(
|
|
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
text_box.addWidget(title_lbl)
|
|
|
|
val_lbl = QLabel(
|
|
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
|
f"{value}</span>"
|
|
)
|
|
val_lbl.setTextFormat(Qt.RichText)
|
|
val_lbl.setStyleSheet("background: transparent; border: none;")
|
|
text_box.addWidget(val_lbl)
|
|
|
|
if subtitle:
|
|
sub_lbl = QLabel(subtitle)
|
|
sub_lbl.setStyleSheet(
|
|
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
sub_lbl.setWordWrap(True)
|
|
text_box.addWidget(sub_lbl)
|
|
|
|
outer.addLayout(text_box, 1)
|
|
card.setLayout(outer)
|
|
return card
|
|
|
|
|
|
def build_section_card(title: str, content: QWidget,
|
|
theme: str = 'gray', icon: str = '') -> QFrame:
|
|
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
|
card = QFrame()
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
|
border: 1px solid {t['border']};
|
|
border-radius: 10px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
|
""")
|
|
layout = QVBoxLayout()
|
|
layout.setContentsMargins(16, 12, 16, 14)
|
|
layout.setSpacing(8)
|
|
|
|
head = QHBoxLayout()
|
|
if icon:
|
|
i = QLabel(icon)
|
|
i.setStyleSheet(
|
|
f"font-size: 16pt; color: {t['border_strong']}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
head.addWidget(i)
|
|
title_lbl = QLabel(title)
|
|
title_lbl.setStyleSheet(
|
|
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
head.addWidget(title_lbl)
|
|
head.addStretch()
|
|
layout.addLayout(head)
|
|
|
|
layout.addWidget(content, 1)
|
|
card.setLayout(layout)
|
|
return card
|
|
|
|
|
|
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
|
height: int = 10) -> None:
|
|
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
|
pb.setMinimumHeight(height)
|
|
pb.setMaximumHeight(height)
|
|
pb.setTextVisible(False)
|
|
pb.setStyleSheet(f"""
|
|
QProgressBar {{
|
|
background: rgba(0,0,0,0.4);
|
|
border: none;
|
|
border-radius: {height // 2}px;
|
|
}}
|
|
QProgressBar::chunk {{
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
|
border-radius: {height // 2}px;
|
|
}}
|
|
""")
|
|
|
|
|
|
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
|
color: str = DARK_TEXT) -> QLabel:
|
|
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
|
lbl = QLabel(text)
|
|
weight_str = 'bold' if weight == 'bold' else 'normal'
|
|
lbl.setStyleSheet(
|
|
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
|
f"background: transparent; border: none; padding: 0; margin: 0;"
|
|
)
|
|
return lbl
|