""" 도전과제 뷰 — 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: #1A1B1E; }") 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 #2C2E33; 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: #2C2E33;") 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: #6C6E73; 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 = '#6C6E73' 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: #25262B; 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: #25262B; color: #ffd24a; font-weight: bold; border-bottom: 2px solid #ffd24a; } QTabBar::tab:hover:!selected { background: #2a2a36; color: #e0e0e8; } """