// 🎨 프로필 커스텀 화면 (UI 전용). 공부시간으로 적립한 캐시로 프로필 카드 테마를 // 구매/장착하는 상점. 서버 통신/상태는 lib/function/profile_shop_controller.dart가 담당한다. import 'package:flutter/material.dart'; import '../function/profile_shop_controller.dart'; import '../theme/app_palette.dart'; import '../theme/profile_themes.dart'; import 'app_notice.dart'; import 'title_pill.dart'; class ProfileCustomizeScreen extends StatefulWidget { final String studentId; final String studentName; const ProfileCustomizeScreen({ super.key, required this.studentId, required this.studentName, }); @override State createState() => _ProfileCustomizeScreenState(); } class _ProfileCustomizeScreenState extends State { final ProfileShopController _controller = ProfileShopController(); // 🚧 첫 버전은 "기본 프로필 꾸미기"만 실제로 동작한다. 나머지 두 카테고리는 // 와이어프레임 자리만 잡아둔 준비중 상태. String _selectedCategory = 'basic_profile'; // 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태. // null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중. int? _previewTemplateId; // 🎨 지금 실제로 장착 중인 테마 - 화면 배경/상점 타일 자체를 이걸로 물들인다. // (미리보기는 큰 타일 하나만 바뀌는 것과 별개로, 이건 "진짜 내 테마"를 화면 전체에 반영) ProfileTheme? _equippedTheme; @override void initState() { super.initState(); _controller.loadCatalog(widget.studentId); _loadEquippedTheme(); } @override void dispose() { _controller.dispose(); super.dispose(); } Future _loadEquippedTheme() async { final theme = await ProfileShopController.fetchEquippedTheme( widget.studentId, ); if (!mounted) return; setState(() => _equippedTheme = theme); } /// 지금 큰 타일에 보여줄 테마를 고른다. 직접 미리보기를 누른 게 있으면 그걸, /// 없으면 실제 장착 중인 테마를, 그마저도 없으면 null(기본 모습)을 돌려준다. ShopTemplate? _resolvePreviewTemplate() { if (_previewTemplateId == -1) return null; if (_previewTemplateId != null) { for (final t in _controller.templates) { if (t.id == _previewTemplateId) return t; } } for (final t in _controller.templates) { if (t.equipped) return t; } return null; } Future _handlePurchase(ShopTemplate template) async { setState(() => _previewTemplateId = template.id); final (success, message) = await _controller.purchase( widget.studentId, template.id, ); if (!mounted) return; AppNotice.show( context, message, color: success ? AppPalette.success : AppPalette.danger, ); } Future _handleEquip(int? templateId) async { setState(() => _previewTemplateId = templateId ?? -1); final (success, message) = await _controller.equip( widget.studentId, templateId, ); if (success) await _loadEquippedTheme(); if (!mounted) return; AppNotice.show( context, message, color: success ? AppPalette.approved : AppPalette.danger, ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _equippedTheme?.background ?? AppPalette.mist, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, centerTitle: true, title: const TitlePill('프로필 꾸미기'), ), body: ListenableBuilder( listenable: _controller, builder: (context, _) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: RefreshIndicator( onRefresh: () => _controller.loadCatalog(widget.studentId), child: ListView( padding: const EdgeInsets.all(20), children: [ _buildCashBalance(), const SizedBox(height: 20), _buildCategoryTiles(), const SizedBox(height: 24), if (_controller.isLoading) const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center(child: CircularProgressIndicator()), ) else _buildTemplateGrid(), ], ), ), ), ); }, ), ); } Widget _buildCashBalance() { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), decoration: BoxDecoration( color: _equippedTheme?.secondary ?? AppPalette.ink, borderRadius: BorderRadius.circular(18), ), child: Row( children: [ const Icon(Icons.savings_rounded, color: Colors.white, size: 22), const SizedBox(width: 10), const Text( '보유 캐시', style: TextStyle(color: Colors.white70, fontSize: 13), ), const Spacer(), Text( '${_controller.cashBalance} 캐시', style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), ], ), ); } Widget _buildCategoryTiles() { final previewTemplate = _resolvePreviewTemplate(); // 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에 // stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다. return SizedBox( height: 210, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: 3, child: _CategoryTile( title: '기본 프로필 꾸미기', icon: Icons.person_rounded, selected: _selectedCategory == 'basic_profile', enabled: true, onTap: () => setState(() => _selectedCategory = 'basic_profile'), previewPrimary: previewTemplate?.primaryColor, previewSecondary: previewTemplate?.secondaryColor, ), ), const SizedBox(width: 12), Expanded( flex: 4, child: Column( children: [ Expanded( child: _CategoryTile( title: '랭킹 · 친구창 배경', subtitle: '준비중', icon: Icons.leaderboard_rounded, selected: false, enabled: false, onTap: null, ), ), const SizedBox(height: 12), Expanded( child: _CategoryTile( title: '출석 · 그룹창 표시', subtitle: '준비중', icon: Icons.groups_rounded, selected: false, enabled: false, onTap: null, ), ), ], ), ), ], ), ); } Widget _buildTemplateGrid() { final templates = _controller.templates .where((t) => t.category == _selectedCategory) .toList(); final hasEquippedTemplate = templates.any((t) => t.equipped); final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중. return GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.15, children: [ _DefaultTemplateTile( equipped: !hasEquippedTemplate, previewing: previewId == null, theme: _equippedTheme, onEquip: () => _handleEquip(null), onPreview: () => setState(() => _previewTemplateId = -1), ), for (final template in templates) _TemplateTile( template: template, canAfford: _controller.cashBalance >= template.price, previewing: previewId == template.id, theme: _equippedTheme, onPurchase: () => _handlePurchase(template), onEquip: () => _handleEquip(template.id), onPreview: () => setState(() => _previewTemplateId = template.id), ), ], ); } } class _CategoryTile extends StatelessWidget { final String title; final String? subtitle; final IconData icon; final bool selected; final bool enabled; final VoidCallback? onTap; // 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용). final Color? previewPrimary; final Color? previewSecondary; const _CategoryTile({ required this.title, this.subtitle, required this.icon, required this.selected, required this.enabled, required this.onTap, this.previewPrimary, this.previewSecondary, }); @override Widget build(BuildContext context) { final bool hasPreview = selected && previewPrimary != null; return Material( color: hasPreview ? Colors.transparent : (selected ? AppPalette.ink : (enabled ? AppPalette.paper : AppPalette.linen)), borderRadius: BorderRadius.circular(20), child: InkWell( borderRadius: BorderRadius.circular(20), onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: hasPreview ? LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [previewPrimary!, previewSecondary!], ) : null, border: Border.all( color: hasPreview ? previewSecondary! : (selected ? AppPalette.ink : AppPalette.sage), width: hasPreview ? 2 : 1, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 22, color: selected ? Colors.white : (enabled ? AppPalette.ink : Colors.grey), ), const SizedBox(height: 8), Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: selected ? Colors.white : (enabled ? AppPalette.ink : Colors.grey[600]), ), ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: Colors.grey[500]), ), ], ], ), ), ), ); } } class _DefaultTemplateTile extends StatelessWidget { final bool equipped; final bool previewing; final ProfileTheme? theme; final VoidCallback onEquip; final VoidCallback onPreview; const _DefaultTemplateTile({ required this.equipped, required this.previewing, required this.theme, required this.onEquip, required this.onPreview, }); @override Widget build(BuildContext context) { final onBackground = theme?.onBackground ?? AppPalette.ink; final borderColor = equipped ? AppPalette.approved : (previewing ? onBackground : (theme?.border ?? AppPalette.sage)); final borderWidth = equipped || previewing ? 2.0 : 1.0; return Material( color: theme?.surface ?? AppPalette.paper, borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: onPreview, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all(color: borderColor, width: borderWidth), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( color: AppPalette.ink, borderRadius: BorderRadius.circular(12), ), ), ), const SizedBox(height: 10), Row( children: [ Expanded( child: Text( '기본 모습', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: onBackground, ), ), ), if (previewing && !equipped) Text( '미리보기', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: onBackground, ), ), ], ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: equipped ? OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( foregroundColor: AppPalette.approved, side: const BorderSide(color: AppPalette.approved), ), child: const Text('장착중'), ) : OutlinedButton( onPressed: onEquip, style: OutlinedButton.styleFrom( foregroundColor: theme?.accent, side: theme == null ? null : BorderSide(color: theme!.accent), ), child: const Text('장착하기'), ), ), ], ), ), ), ); } } class _TemplateTile extends StatelessWidget { final ShopTemplate template; final bool canAfford; final bool previewing; final ProfileTheme? theme; final VoidCallback onPurchase; final VoidCallback onEquip; final VoidCallback onPreview; const _TemplateTile({ required this.template, required this.canAfford, required this.previewing, required this.theme, required this.onPurchase, required this.onEquip, required this.onPreview, }); @override Widget build(BuildContext context) { final onBackground = theme?.onBackground ?? AppPalette.ink; final borderColor = template.equipped ? AppPalette.approved : (previewing ? onBackground : (theme?.border ?? AppPalette.sage)); final borderWidth = template.equipped || previewing ? 2.0 : 1.0; return Material( color: theme?.surface ?? AppPalette.paper, borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: onPreview, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all(color: borderColor, width: borderWidth), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [template.primaryColor, template.secondaryColor], ), borderRadius: BorderRadius.circular(12), ), ), ), const SizedBox(height: 10), Row( children: [ Expanded( child: Text( template.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: onBackground, ), ), ), if (previewing && !template.equipped) Text( '미리보기', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: onBackground, ), ), ], ), const SizedBox(height: 2), Text( '${template.price} 캐시', style: TextStyle( fontSize: 12, color: onBackground.withValues(alpha: 0.6), ), ), const SizedBox(height: 8), if (template.equipped) SizedBox( width: double.infinity, child: OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( foregroundColor: AppPalette.approved, side: const BorderSide(color: AppPalette.approved), ), child: const Text('장착중'), ), ) else if (template.owned) SizedBox( width: double.infinity, child: OutlinedButton( onPressed: onEquip, style: OutlinedButton.styleFrom( foregroundColor: theme?.accent, side: theme == null ? null : BorderSide(color: theme!.accent), ), child: const Text('장착하기'), ), ) else Row( children: [ Expanded( child: OutlinedButton( onPressed: onPreview, style: OutlinedButton.styleFrom( foregroundColor: theme?.accent, side: theme == null ? null : BorderSide(color: theme!.accent), ), child: const Text('체험하기'), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton( onPressed: canAfford ? onPurchase : null, style: ElevatedButton.styleFrom( backgroundColor: theme?.secondary ?? AppPalette.ink, foregroundColor: Colors.white, disabledBackgroundColor: AppPalette.sage, ), child: const Text('구매하기'), ), ), ], ), ], ), ), ), ); } }