- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅) - fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종 - dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""
|
|
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
|
|
|
|
디자인 원칙:
|
|
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
|
|
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
|
|
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
|
|
- 카테고리 = 작은 인라인 태그
|
|
- 시크릿 미발견 = ❓ 처리
|
|
"""
|
|
from __future__ import annotations
|
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QPushButton, QTabWidget, QWidget, QScrollArea,
|
|
QProgressBar, QFrame, QGridLayout, QSizePolicy)
|
|
from PyQt5.QtCore import Qt
|
|
from PyQt5.QtGui import QFont
|
|
|
|
from core.achievements import get_all_with_status, get_stats
|
|
from ui.styles import apply_dark_titlebar
|
|
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
|
|
|
|
|
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
|
TIER_THEMES = {
|
|
'bronze': {
|
|
'border': '#cd7f32',
|
|
'border_strong': '#e09947',
|
|
'bg_top': '#3a2a18',
|
|
'bg_bot': '#241810',
|
|
'text': '#ffd9a8',
|
|
'label': '🥉',
|
|
'name': '브론즈',
|
|
},
|
|
'silver': {
|
|
'border': '#a8a8a8',
|
|
'border_strong': '#d0d0d0',
|
|
'bg_top': '#2e2e36',
|
|
'bg_bot': '#1c1c22',
|
|
'text': '#e8e8f0',
|
|
'label': '🥈',
|
|
'name': '실버',
|
|
},
|
|
'gold': {
|
|
'border': '#ffb700',
|
|
'border_strong': '#ffd24a',
|
|
'bg_top': '#3a2e10',
|
|
'bg_bot': '#241c08',
|
|
'text': '#ffe9a0',
|
|
'label': '🥇',
|
|
'name': '골드',
|
|
},
|
|
'platinum': {
|
|
'border': '#7fdbff',
|
|
'border_strong': '#a8e8ff',
|
|
'bg_top': '#1a3340',
|
|
'bg_bot': '#0e1f28',
|
|
'text': '#c5ecff',
|
|
'label': '💎',
|
|
'name': '플래티넘',
|
|
},
|
|
'legend': {
|
|
'border': '#ff6b9d',
|
|
'border_strong': '#ff90b8',
|
|
'bg_top': '#3a1a2a',
|
|
'bg_bot': '#26101a',
|
|
'text': '#ffc0d4',
|
|
'label': '🌟',
|
|
'name': '레전드',
|
|
},
|
|
}
|
|
|
|
CATEGORY_LABELS = {
|
|
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
|
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
|
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
|
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
|
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
|
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
|
'ambition': '야망', 'meta': '메타',
|
|
}
|
|
|
|
|
|
class AchievementsView(QDialog):
|
|
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
|
|
|
def __init__(self, db, parent=None):
|
|
super().__init__(parent)
|
|
self.db = db
|
|
self.setWindowTitle("도전과제")
|
|
self.setMinimumSize(960, 720)
|
|
self.resize(1100, 800)
|
|
self._increment_view_count()
|
|
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
|
|
self.init_ui()
|
|
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
|
|
|
def _increment_view_count(self) -> None:
|
|
try:
|
|
cur = self.db.get_setting_int('achievements_view_count', 0)
|
|
self.db.set_setting('achievements_view_count', str(cur + 1))
|
|
except Exception:
|
|
pass
|
|
|
|
def init_ui(self) -> None:
|
|
layout = QVBoxLayout()
|
|
layout.setContentsMargins(20, 20, 20, 16)
|
|
layout.setSpacing(12)
|
|
|
|
stats = get_stats(self.db)
|
|
|
|
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
|
|
layout.addWidget(self._build_header(stats))
|
|
|
|
# === 탭 ===
|
|
self.tabs = QTabWidget()
|
|
self.tabs.setStyleSheet(self._tabs_qss())
|
|
|
|
all_items = get_all_with_status(self.db)
|
|
earned_items = [a for a in all_items if a['earned_date'] is not None]
|
|
in_progress = [a for a in all_items
|
|
if a['earned_date'] is None and not a['is_secret']]
|
|
secret_items = [a for a in all_items if a['is_secret']]
|
|
|
|
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
|
self.tabs.addTab(self._build_grid_tab(in_progress),
|
|
f"⚡ 진행 중 · {len(in_progress)}")
|
|
self.tabs.addTab(self._build_grid_tab(earned_items),
|
|
f"✓ 완료 · {len(earned_items)}")
|
|
self.tabs.addTab(
|
|
self._build_grid_tab(secret_items, secret_mode=True),
|
|
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
|
)
|
|
layout.addWidget(self.tabs, 1)
|
|
|
|
# === 닫기 버튼 ===
|
|
btn_row = QHBoxLayout()
|
|
btn_row.addStretch()
|
|
close_btn = QPushButton("닫기")
|
|
close_btn.setMinimumWidth(100)
|
|
close_btn.setStyleSheet(button_qss('default'))
|
|
close_btn.clicked.connect(self.accept)
|
|
btn_row.addWidget(close_btn)
|
|
layout.addLayout(btn_row)
|
|
|
|
self.setLayout(layout)
|
|
|
|
# ----- 헤더 -----
|
|
def _build_header(self, stats: dict) -> QWidget:
|
|
container = QFrame()
|
|
container.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: {tc('panel')};
|
|
border: 1px solid {tc('border')};
|
|
border-radius: 12px;
|
|
}}
|
|
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
|
""")
|
|
layout = QVBoxLayout()
|
|
layout.setContentsMargins(20, 16, 20, 16)
|
|
layout.setSpacing(8)
|
|
|
|
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
|
|
|
|
# 큰 숫자 행
|
|
num_row = QHBoxLayout()
|
|
num_row.setSpacing(24)
|
|
|
|
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
|
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
|
|
big.setTextFormat(Qt.RichText)
|
|
num_row.addWidget(big)
|
|
|
|
spacer = QFrame()
|
|
spacer.setFrameShape(QFrame.VLine)
|
|
spacer.setStyleSheet(f"color: {tc('border')};")
|
|
num_row.addWidget(spacer)
|
|
|
|
secret_lbl = QLabel(
|
|
f"<div style='line-height: 1.3;'>"
|
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
|
|
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
|
f"{stats['secret_earned']}</span>"
|
|
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
|
f"</div>"
|
|
)
|
|
secret_lbl.setTextFormat(Qt.RichText)
|
|
num_row.addWidget(secret_lbl)
|
|
|
|
num_row.addStretch()
|
|
|
|
pct_lbl = QLabel(
|
|
f"<div style='text-align: right; line-height: 1.3;'>"
|
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>달성률</span><br>"
|
|
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
|
f"{pct:.1f}%</span></div>"
|
|
)
|
|
pct_lbl.setTextFormat(Qt.RichText)
|
|
pct_lbl.setAlignment(Qt.AlignRight)
|
|
num_row.addWidget(pct_lbl)
|
|
|
|
layout.addLayout(num_row)
|
|
|
|
# 진행 바
|
|
bar = QProgressBar()
|
|
bar.setMaximum(max(stats['total'], 1))
|
|
bar.setValue(stats['earned'])
|
|
bar.setTextVisible(False)
|
|
bar.setMinimumHeight(8)
|
|
bar.setMaximumHeight(8)
|
|
bar.setStyleSheet("""
|
|
QProgressBar {
|
|
background: #1a1a26;
|
|
border: none;
|
|
border-radius: 4px;
|
|
}
|
|
QProgressBar::chunk {
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
layout.addWidget(bar)
|
|
|
|
container.setLayout(layout)
|
|
return container
|
|
|
|
# ----- 탭 그리드 -----
|
|
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setStyleSheet(scroll_qss())
|
|
container = QWidget()
|
|
container.setStyleSheet("background: transparent;")
|
|
grid = QGridLayout()
|
|
grid.setSpacing(12)
|
|
grid.setContentsMargins(8, 8, 8, 8)
|
|
|
|
if not items:
|
|
empty = QLabel("(아직 없음)")
|
|
empty.setAlignment(Qt.AlignCenter)
|
|
empty.setStyleSheet(
|
|
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
|
)
|
|
grid.addWidget(empty, 0, 0)
|
|
else:
|
|
cols = 3
|
|
for i, item in enumerate(items):
|
|
card = self._build_card(item, secret_mode=secret_mode)
|
|
grid.addWidget(card, i // cols, i % cols)
|
|
# 빈 컬럼 stretch 방지
|
|
for c in range(cols):
|
|
grid.setColumnStretch(c, 1)
|
|
|
|
container.setLayout(grid)
|
|
scroll.setWidget(container)
|
|
return scroll
|
|
|
|
# ----- 단일 카드 -----
|
|
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
|
|
is_earned = item['earned_date'] is not None
|
|
is_locked_secret = item['is_secret'] and not is_earned
|
|
tier = item['tier'] or 'bronze'
|
|
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
|
|
|
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
|
light = not _is_dark()
|
|
if is_locked_secret:
|
|
if light:
|
|
bg_top = bg_bot = tc('panel'); border = tc('border')
|
|
else:
|
|
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
|
|
text_color = tc('text_faint')
|
|
elif light:
|
|
bg_top = bg_bot = tc('panel')
|
|
border = theme['border_strong'] if is_earned else theme['border']
|
|
text_color = tc('text') if is_earned else tc('text_dim')
|
|
else:
|
|
bg_top = theme['bg_top']
|
|
bg_bot = theme['bg_bot']
|
|
border = theme['border_strong'] if is_earned else theme['border']
|
|
text_color = theme['text'] if is_earned else '#c0c0d0'
|
|
|
|
# 외곽선 강도: 획득 시 2px + 더 진한 색
|
|
border_width = 2 if is_earned else 1
|
|
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
|
|
|
|
card = QFrame()
|
|
card.setFrameShape(QFrame.NoFrame)
|
|
card.setMinimumHeight(150)
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
card.setStyleSheet(f"""
|
|
QFrame {{
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 {bg_top}, stop:1 {bg_bot});
|
|
border: {border_width}px solid {border};
|
|
border-radius: 10px;
|
|
}}
|
|
QLabel {{
|
|
background: transparent;
|
|
border: none;
|
|
color: {text_color};
|
|
}}
|
|
""")
|
|
|
|
outer = QVBoxLayout()
|
|
outer.setContentsMargins(14, 12, 14, 12)
|
|
outer.setSpacing(8)
|
|
|
|
# 1행: 이모지 + 이름 + 등급 라벨
|
|
top_row = QHBoxLayout()
|
|
top_row.setSpacing(10)
|
|
|
|
if is_locked_secret:
|
|
icon_text = "❓"
|
|
else:
|
|
icon_text = item['badge_icon'] or '🏆'
|
|
icon = QLabel(icon_text)
|
|
icon.setStyleSheet(
|
|
f"font-size: 32pt; background: transparent; border: none; "
|
|
f"color: {text_color};"
|
|
)
|
|
icon.setMinimumWidth(48)
|
|
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
|
|
top_row.addWidget(icon)
|
|
|
|
# 이름 + 카테고리 (세로 스택)
|
|
name_box = QVBoxLayout()
|
|
name_box.setSpacing(2)
|
|
name_box.setContentsMargins(0, 4, 0, 0)
|
|
|
|
name_text = "???" if is_locked_secret else (item['name'] or '')
|
|
name = QLabel(name_text)
|
|
name.setStyleSheet(
|
|
f"font-size: 12pt; font-weight: bold; "
|
|
f"color: {tc('text') if is_earned else tc('text_dim')}; "
|
|
f"background: transparent; border: none;"
|
|
)
|
|
name.setWordWrap(True)
|
|
name_box.addWidget(name)
|
|
|
|
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
|
if not is_locked_secret:
|
|
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
|
cat_label.setStyleSheet(
|
|
f"font-size: 8.5pt; "
|
|
f"color: {theme['border_strong']}; "
|
|
f"background: rgba(255,255,255,0.05); "
|
|
f"border: 1px solid {theme['border']}; "
|
|
f"border-radius: 8px; "
|
|
f"padding: 1px 4px;"
|
|
)
|
|
cat_label.setMaximumHeight(20)
|
|
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
|
cat_wrap = QHBoxLayout()
|
|
cat_wrap.setContentsMargins(0, 0, 0, 0)
|
|
cat_wrap.addWidget(cat_label)
|
|
cat_wrap.addStretch()
|
|
name_box.addLayout(cat_wrap)
|
|
|
|
top_row.addLayout(name_box, 1)
|
|
outer.addLayout(top_row)
|
|
|
|
# 2행: 설명
|
|
if is_locked_secret:
|
|
desc_text = "🔒 달성하면 공개됩니다"
|
|
else:
|
|
desc_text = item['description'] or ''
|
|
desc = QLabel(desc_text)
|
|
desc.setWordWrap(True)
|
|
desc.setStyleSheet(
|
|
f"color: {tc('text_dim')}; font-size: 9.5pt; "
|
|
f"background: transparent; border: none; padding: 0;"
|
|
)
|
|
outer.addWidget(desc)
|
|
|
|
# 3행: 진행 게이지 또는 획득 일자
|
|
if is_earned:
|
|
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
|
earned.setStyleSheet(
|
|
f"color: {theme['border_strong']}; "
|
|
f"font-weight: bold; font-size: 9.5pt; "
|
|
f"background: rgba(255,255,255,0.08); "
|
|
f"border: 1px solid {theme['border']}; "
|
|
f"border-radius: 6px; padding: 4px 8px;"
|
|
)
|
|
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
|
row = QHBoxLayout()
|
|
row.addWidget(earned)
|
|
row.addStretch()
|
|
outer.addLayout(row)
|
|
elif not is_locked_secret:
|
|
target = max(1, item.get('target') or 1)
|
|
progress = item.get('progress') or 0
|
|
pct = (progress / target * 100) if target else 0
|
|
|
|
# 게이지 + 숫자 라벨
|
|
gauge_row = QHBoxLayout()
|
|
gauge_row.setSpacing(8)
|
|
|
|
pb = QProgressBar()
|
|
pb.setMaximum(target)
|
|
pb.setValue(min(progress, target))
|
|
pb.setTextVisible(False)
|
|
pb.setMinimumHeight(10)
|
|
pb.setMaximumHeight(10)
|
|
pb.setStyleSheet(f"""
|
|
QProgressBar {{
|
|
background: rgba(0,0,0,0.4);
|
|
border: none;
|
|
border-radius: 5px;
|
|
}}
|
|
QProgressBar::chunk {{
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
|
|
border-radius: 5px;
|
|
}}
|
|
""")
|
|
gauge_row.addWidget(pb, 1)
|
|
|
|
num = QLabel(f"{progress} / {target}")
|
|
num.setStyleSheet(
|
|
f"color: {theme['border_strong']}; font-size: 9pt; "
|
|
f"font-weight: bold; background: transparent; border: none;"
|
|
)
|
|
num.setMinimumWidth(60)
|
|
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
gauge_row.addWidget(num)
|
|
|
|
outer.addLayout(gauge_row)
|
|
else:
|
|
# 시크릿 잠금 — 회색 점선 placeholder
|
|
placeholder = QLabel("· · · · · · · · · ·")
|
|
placeholder.setStyleSheet(
|
|
"color: #444; font-size: 12pt; letter-spacing: 4px; "
|
|
"background: transparent; border: none;"
|
|
)
|
|
placeholder.setAlignment(Qt.AlignCenter)
|
|
outer.addWidget(placeholder)
|
|
|
|
outer.addStretch(1)
|
|
card.setLayout(outer)
|
|
return card
|
|
|
|
# ----- 탭 QSS (다이얼로그 전용) -----
|
|
def _tabs_qss(self) -> str:
|
|
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
|
return tabs_qss(ACCENT_GOLD)
|