"""
도전과제 뷰 — 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
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
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("QDialog { background: #0e0e14; }")
self.init_ui()
apply_dark_titlebar(self, dark=True)
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("""
QPushButton {
background: #2a2a36; color: #e0e0e8;
border: 1px solid #44446a; border-radius: 6px;
padding: 8px 20px; font-size: 10pt;
}
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
""")
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("""
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: #e8e8f4; }
""")
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"{stats['earned']}"
f" / {stats['total']}")
big.setTextFormat(Qt.RichText)
num_row.addWidget(big)
spacer = QFrame()
spacer.setFrameShape(QFrame.VLine)
spacer.setStyleSheet("color: #3a3a5a;")
num_row.addWidget(spacer)
secret_lbl = QLabel(
f"
"
f"🌑 시크릿
"
f""
f"{stats['secret_earned']}"
f" / {stats['secret_total']}"
f"
"
)
secret_lbl.setTextFormat(Qt.RichText)
num_row.addWidget(secret_lbl)
num_row.addStretch()
pct_lbl = QLabel(
f""
f"달성률
"
f""
f"{pct:.1f}%
"
)
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("""
QScrollArea { background: transparent; border: none; }
QScrollBar:vertical {
background: #1a1a24; width: 10px; border-radius: 5px;
}
QScrollBar::handle:vertical {
background: #44446a; border-radius: 5px; min-height: 30px;
}
QScrollBar::handle:vertical:hover { background: #6b9eff; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
""")
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(
"color: #666; 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'])
# 시크릿 미발견은 회색 톤으로
if is_locked_secret:
bg_top, bg_bot = '#1a1a26', '#0e0e16'
border = '#3a3a4a'
text_color = '#666'
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: {'#ffffff' if is_earned else '#d0d0e0'}; "
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: #a0a0b8; 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 """
QTabWidget::pane {
background: #14141c;
border: 1px solid #2a2a3a;
border-radius: 10px;
top: -1px;
}
QTabBar::tab {
background: #1c1c28;
color: #a0a0b8;
padding: 9px 18px;
border: 1px solid #2a2a3a;
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 3px;
font-size: 10pt;
}
QTabBar::tab:selected {
background: #14141c;
color: #ffd24a;
font-weight: bold;
border-bottom: 2px solid #ffd24a;
}
QTabBar::tab:hover:!selected {
background: #2a2a36;
color: #e0e0e8;
}
"""