템플릿마다 메인 대시보드 전체 테마(배경/타일/아이콘 색)가 같이 바뀌게 추가

용어 정리: 템플릿 = 상점에서 사는 색/애니메이션 상품, 테마 = 그 템플릿이
몰고 오는 화면 전체 디자인(배경색/타일색/아이콘색). 1단계로 메인 대시보드부터
적용 - 프로필 카드 그라데이션뿐 아니라 페이지 배경, 타일 카드 배경/테두리,
아이콘 동그라미 배경, 타일 아이콘 강조색, 제목/부제목 텍스트 색까지 장착한
템플릿에 맞춰 통째로 바뀐다. 잠긴/비활성 타일(NFC 미인증 등)은 기존처럼
중립 회색(sage)을 그대로 써서 상태 구분은 유지.

6개 기존 템플릿마다 직접 디자인한 테마(lib/theme/profile_themes.dart)를
새로 만들었고, 최고가 "갤럭시" 템플릿은 다른 5개와 달리 통째로 다크 테마로
디자인해 프리미엄 아이템 느낌을 살렸다.

백엔드 /api/shop/equipped가 템플릿 code를 같이 내려주도록 수정해서
프론트엔드가 code로 디자인해둔 테마를 찾아 쓸 수 있게 했다.
This commit is contained in:
2026-10-01 06:13:18 +00:00
parent edf0444b71
commit 0fc80eb5c5
3 changed files with 146 additions and 45 deletions
+4 -8
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show Color; import 'package:flutter/material.dart' show Color;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config.dart'; import '../config.dart';
import '../theme/profile_themes.dart';
class ShopTemplate { class ShopTemplate {
final int id; final int id;
@@ -125,9 +126,7 @@ class ProfileShopController extends ChangeNotifier {
/// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도 /// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도
/// 현재 장착 중인 테마 색상만 가볍게 조회한다. /// 현재 장착 중인 테마 색상만 가볍게 조회한다.
static Future<(Color? primary, Color? secondary)> fetchEquippedColors( static Future<ProfileTheme?> fetchEquippedTheme(String studentId) async {
String studentId,
) async {
try { try {
final url = Uri.parse( final url = Uri.parse(
'$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}', '$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}',
@@ -136,15 +135,12 @@ class ProfileShopController extends ChangeNotifier {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
if (data['equipped'] == true) { if (data['equipped'] == true) {
return ( return profileThemes[data['code'] as String?];
_parseColor(data['primaryColor'] as String),
_parseColor(data['secondaryColor'] as String),
);
} }
} }
} catch (_) { } catch (_) {
// 실패하면 그냥 기본 모습으로 보여준다. // 실패하면 그냥 기본 모습으로 보여준다.
} }
return (null, null); return null;
} }
} }
+98
View File
@@ -0,0 +1,98 @@
// 🎨 프로필 꾸미기 "템플릿"을 장착하면 프로필 카드 색뿐 아니라 화면 전체의
// 배경/타일/아이콘 색까지 통째로 바뀌는 "테마" 데이터.
// 템플릿은 상점에서 사는 색/애니메이션 상품, 테마는 그 템플릿이 몰고 오는
// 화면 전체 디자인(배경색/타일색/아이콘색/모양)을 가리킨다 - 용어 구분은
// 사장님이 정해준 것을 그대로 따른다.
//
// 템플릿 하나하나에 대해 직접 디자인한 고정 팔레트라서, 서버에서 내려주는
// code(예: "lavender")로 이 맵을 찾아 쓴다. 템플릿이 늘어나면 여기도 추가해야 한다.
import 'package:flutter/material.dart';
class ProfileTheme {
final Color primary; // 프로필 카드 그라데이션 시작색 (템플릿 색)
final Color secondary; // 프로필 카드 그라데이션 끝색 (템플릿 색)
final Color background; // 페이지 배경
final Color surface; // 타일/카드 배경
final Color surfaceAlt; // 아이콘 동그라미 등 보조 표면
final Color border; // 타일 테두리/구분선
final Color accent; // 타일 아이콘 등 강조색
final Color onBackground; // 배경/타일 위에 올라가는 기본 텍스트 색
final bool isDark;
const ProfileTheme({
required this.primary,
required this.secondary,
required this.background,
required this.surface,
required this.surfaceAlt,
required this.border,
required this.accent,
required this.onBackground,
this.isDark = false,
});
}
const Map<String, ProfileTheme> profileThemes = {
'lavender': ProfileTheme(
primary: Color(0xFFB39DDB),
secondary: Color(0xFF7E57C2),
background: Color(0xFFF5F0FA),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFEDE4F7),
border: Color(0xFFDCCCEF),
accent: Color(0xFF7E57C2),
onBackground: Color(0xFF1C1C1C),
),
'mint': ProfileTheme(
primary: Color(0xFF80CBC4),
secondary: Color(0xFF00897B),
background: Color(0xFFEAF6F4),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFDBF0EC),
border: Color(0xFFBFE3DC),
accent: Color(0xFF00897B),
onBackground: Color(0xFF1C1C1C),
),
'sunset': ProfileTheme(
primary: Color(0xFFFFB74D),
secondary: Color(0xFFE64A19),
background: Color(0xFFFFF3E6),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFFFE5CC),
border: Color(0xFFFFD1A6),
accent: Color(0xFFE64A19),
onBackground: Color(0xFF1C1C1C),
),
'ocean': ProfileTheme(
primary: Color(0xFF4FC3F7),
secondary: Color(0xFF01579B),
background: Color(0xFFEAF6FC),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFD9EEF9),
border: Color(0xFFBBDDF0),
accent: Color(0xFF01579B),
onBackground: Color(0xFF1C1C1C),
),
'aurora': ProfileTheme(
primary: Color(0xFF81C784),
secondary: Color(0xFF1565C0),
background: Color(0xFFEDF6EF),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFDCEEE0),
border: Color(0xFFC3E2CA),
accent: Color(0xFF1565C0),
onBackground: Color(0xFF1C1C1C),
),
// 🌌 최고가 템플릿 - 다른 5개와 달리 통째로 다크 테마라서 들어가자마자 확 다르게 느껴진다.
'galaxy': ProfileTheme(
primary: Color(0xFF9575CD),
secondary: Color(0xFF1A237E),
background: Color(0xFF14122B),
surface: Color(0xFF211F42),
surfaceAlt: Color(0xFF2C2A52),
border: Color(0xFF3A3768),
accent: Color(0xFFB39DDB),
onBackground: Color(0xFFF5F3FF),
isDark: true,
),
};
+44 -37
View File
@@ -12,6 +12,7 @@ import '../function/session_store.dart';
import '../function/student_dashboard_controller.dart'; import '../function/student_dashboard_controller.dart';
import '../models/dashboard_tile_layout.dart'; import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/profile_themes.dart';
import 'admin_dashboard.dart'; import 'admin_dashboard.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'board_home_screen.dart'; import 'board_home_screen.dart';
@@ -85,17 +86,17 @@ class _MainDashboardState extends State<MainDashboard> {
} }
} }
// 🛍️ 프로필 꾸미기 상점에서 장착한 테마 색상 (없으면 기본 모습 그대로). // 🛍️ 프로필 꾸미기 상점에서 장착한 템플릿이 몰고 오는 테마 (없으면 기본 모습 그대로).
Color? _equippedPrimary; ProfileTheme? _equippedTheme;
Color? _equippedSecondary;
// 🎨 타일 아이콘 등 강조색에 쓸 색 - 테마가 없으면 원래 쓰던 AppPalette.ink.
Color get _tileAccent => _equippedTheme?.accent ?? AppPalette.ink;
Future<void> _loadEquippedTheme() async { Future<void> _loadEquippedTheme() async {
final (primary, secondary) = final theme = await ProfileShopController.fetchEquippedTheme(_displayId);
await ProfileShopController.fetchEquippedColors(_displayId);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_equippedPrimary = primary; _equippedTheme = theme;
_equippedSecondary = secondary;
}); });
} }
@@ -312,7 +313,7 @@ class _MainDashboardState extends State<MainDashboard> {
: Icons.lock_rounded, : Icons.lock_rounded,
title: 'NFC 태그', title: 'NFC 태그',
subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용', subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용',
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage, color: widget.isDeviceMatched ? _tileAccent : AppPalette.sage,
onTap: widget.isDeviceMatched onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context) ? () => _openPocketCheckIn(context)
: () => AppNotice.show( : () => AppNotice.show(
@@ -330,7 +331,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.fastfood_rounded, icon: Icons.fastfood_rounded,
title: '실시간 학교 상황', title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인', subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink, color: _tileAccent,
onTap: () => AppNotice.show( onTap: () => AppNotice.show(
context, context,
'실시간 학교 상황 페이지로 이동합니다.', '실시간 학교 상황 페이지로 이동합니다.',
@@ -344,7 +345,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.tablet_mac_rounded, icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출', title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)', subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => DeviceCheckoutRequestScreen( (context) => DeviceCheckoutRequestScreen(
@@ -360,7 +361,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.timer_rounded, icon: Icons.timer_rounded,
title: '백품타', title: '백품타',
subtitle: '공부 타이머 | 학년별 공부 랭킹', subtitle: '공부 타이머 | 학년별 공부 랭킹',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyTimerScreen( (context) => StudyTimerScreen(
@@ -377,7 +378,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.forum_rounded, icon: Icons.forum_rounded,
title: '백판', title: '백판',
subtitle: '익명 커뮤니티 게시판', subtitle: '익명 커뮤니티 게시판',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => BoardHomeScreen(studentId: _displayId), (context) => BoardHomeScreen(studentId: _displayId),
@@ -390,7 +391,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.campaign_rounded, icon: Icons.campaign_rounded,
title: '선생님 호출', title: '선생님 호출',
subtitle: '교무실 위치 확인 및 호출', subtitle: '교무실 위치 확인 및 호출',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => TeacherCallScreen( (context) => TeacherCallScreen(
@@ -406,7 +407,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.people_alt_rounded, icon: Icons.people_alt_rounded,
title: '친구', title: '친구',
subtitle: '학번으로 친구 추가', subtitle: '학번으로 친구 추가',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => (context) =>
@@ -420,7 +421,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.groups_rounded, icon: Icons.groups_rounded,
title: '그룹 스터디', title: '그룹 스터디',
subtitle: '친구랑 그룹 만들어 같이 공부', subtitle: '친구랑 그룹 만들어 같이 공부',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => GroupListScreen( (context) => GroupListScreen(
@@ -437,7 +438,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.calendar_month_rounded, icon: Icons.calendar_month_rounded,
title: '출석 달력', title: '출석 달력',
subtitle: '연속 출석 기록 확인', subtitle: '연속 출석 기록 확인',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyCalendarScreen(studentId: _displayId), (context) => StudyCalendarScreen(studentId: _displayId),
@@ -453,7 +454,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.assignment_turned_in_rounded, icon: Icons.assignment_turned_in_rounded,
title: '실시간 출석 확인', title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링', subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => const TeacherAttendancePage(), (context) => const TeacherAttendancePage(),
@@ -466,7 +467,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.manage_accounts_rounded, icon: Icons.manage_accounts_rounded,
title: '학생 계정 관리', title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋', subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => const TeacherStudentManagementPage(), (context) => const TeacherStudentManagementPage(),
@@ -479,7 +480,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.tablet_mac_rounded, icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출 대장', title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절', subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => DeviceCheckoutLedgerPage( (context) => DeviceCheckoutLedgerPage(
@@ -495,7 +496,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.shield_outlined, icon: Icons.shield_outlined,
title: '백판 신고 검토', title: '백판 신고 검토',
subtitle: '학생 게시판 신고 처리', subtitle: '학생 게시판 신고 처리',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => BoardReportScreen(reviewerId: _displayId), (context) => BoardReportScreen(reviewerId: _displayId),
@@ -508,7 +509,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.event_available_rounded, icon: Icons.event_available_rounded,
title: '출석 달력 관리', title: '출석 달력 관리',
subtitle: '날짜별 선물/이벤트 등록', subtitle: '날짜별 선물/이벤트 등록',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyCalendarScreen( (context) => StudyCalendarScreen(
@@ -524,7 +525,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.my_location_rounded, icon: Icons.my_location_rounded,
title: '내 위치 알리기', title: '내 위치 알리기',
subtitle: '학생 호출 확인 및 위치 등록', subtitle: '학생 호출 확인 및 위치 등록',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => TeacherLocationScreen(teacherId: _displayId), (context) => TeacherLocationScreen(teacherId: _displayId),
@@ -540,7 +541,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.terminal_rounded, icon: Icons.terminal_rounded,
title: '서버 DB 제어', title: '서버 DB 제어',
subtitle: '시스템 원격 초기화', subtitle: '시스템 원격 초기화',
color: AppPalette.ink, color: _tileAccent,
onTap: () => onTap: () =>
pushLaunchpad(context, (context) => const AdminDashboard()), pushLaunchpad(context, (context) => const AdminDashboard()),
), ),
@@ -551,7 +552,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.delete_sweep_rounded, icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제', title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제', subtitle: '학생 및 교사 DB 삭제',
color: AppPalette.ink, color: _tileAccent,
onTap: () => _showDeleteUserDialog(context), onTap: () => _showDeleteUserDialog(context),
), ),
)); ));
@@ -561,7 +562,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.edit_note_rounded, icon: Icons.edit_note_rounded,
title: 'NFC 태그 쓰기', title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정', subtitle: '주머니 스티커 초기 설정',
color: AppPalette.ink, color: _tileAccent,
onTap: () => onTap: () =>
pushLaunchpad(context, (context) => const NfcTagWriterScreen()), pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
), ),
@@ -623,7 +624,7 @@ class _MainDashboardState extends State<MainDashboard> {
final bool isEditing = _layout.isEditing; final bool isEditing = _layout.isEditing;
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: _equippedTheme?.background ?? AppPalette.mist,
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴 // 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다. // 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
appBar: AppBar( appBar: AppBar(
@@ -672,21 +673,21 @@ class _MainDashboardState extends State<MainDashboard> {
vertical: 18, vertical: 18,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _equippedPrimary == null ? theme.color : null, color: _equippedTheme == null ? theme.color : null,
gradient: _equippedPrimary == null gradient: _equippedTheme == null
? null ? null
: LinearGradient( : LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
_equippedPrimary!, _equippedTheme!.primary,
_equippedSecondary!, _equippedTheme!.secondary,
], ],
), ),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: (_equippedPrimary ?? theme.color) color: (_equippedTheme?.primary ?? theme.color)
.withValues(alpha: 0.25), .withValues(alpha: 0.25),
blurRadius: 16, blurRadius: 16,
offset: const Offset(0, 6), offset: const Offset(0, 6),
@@ -959,7 +960,7 @@ class _MainDashboardState extends State<MainDashboard> {
width: 26, width: 26,
height: 26, height: 26,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.ink, color: _tileAccent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppPalette.paper, color: AppPalette.paper,
@@ -1026,9 +1027,12 @@ class _MainDashboardState extends State<MainDashboard> {
child: Ink( child: Ink(
padding: EdgeInsets.all(cardPadding), padding: EdgeInsets.all(cardPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.paper, color: _equippedTheme?.surface ?? AppPalette.paper,
borderRadius: BorderRadius.circular(cardRadius), borderRadius: BorderRadius.circular(cardRadius),
border: Border.all(color: AppPalette.sage, width: 1), border: Border.all(
color: _equippedTheme?.border ?? AppPalette.sage,
width: 1,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1037,7 +1041,7 @@ class _MainDashboardState extends State<MainDashboard> {
Container( Container(
padding: EdgeInsets.all(iconBoxPadding), padding: EdgeInsets.all(iconBoxPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.linen, color: _equippedTheme?.surfaceAlt ?? AppPalette.linen,
borderRadius: BorderRadius.circular(iconRadius), borderRadius: BorderRadius.circular(iconRadius),
), ),
child: Icon(icon, color: color, size: iconSize), child: Icon(icon, color: color, size: iconSize),
@@ -1056,7 +1060,9 @@ class _MainDashboardState extends State<MainDashboard> {
style: TextStyle( style: TextStyle(
fontSize: titleFontSize, fontSize: titleFontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppPalette.ink, color:
_equippedTheme?.onBackground ??
AppPalette.ink,
), ),
), ),
), ),
@@ -1081,7 +1087,8 @@ class _MainDashboardState extends State<MainDashboard> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: subtitleFontSize, fontSize: subtitleFontSize,
color: AppPalette.ink.withValues(alpha: 0.55), color: (_equippedTheme?.onBackground ?? AppPalette.ink)
.withValues(alpha: 0.55),
height: 1.15, height: 1.15,
), ),
), ),