// ๐ŸŽจ ํ”„๋กœํ•„ ์ปค์Šคํ…€ ํ™”๋ฉด (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 '../theme/theme_sync.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; @override void initState() { super.initState(); _initialize(); } /// ์ƒ์  ํ™”๋ฉด์„ ์—ด ๋•Œ๋งˆ๋‹ค ์„œ๋ฒ„์˜ ์‹ค์ œ ์žฅ์ฐฉ ์ƒํƒœ๋กœ ์ „์—ญ ํ…Œ๋งˆ๋ฅผ ๋‹ค์‹œ ๋งž์ถฐ๋‘”๋‹ค /// (๋”ด ๋ฐ์„œ ์žฅ์ฐฉ ์ƒํƒœ๊ฐ€ ๋ฐ”๋€Œ์—ˆ์„ ์ˆ˜๋„ ์žˆ๋Š” ์—ฃ์ง€ ์ผ€์ด์Šค ๋ณดํ˜ธ์šฉ). Future _initialize() async { await _controller.loadCatalog(widget.studentId); if (!mounted) return; for (final t in _controller.templates) { if (t.equipped) { _syncGlobalTheme(t.id); return; } } _syncGlobalTheme(null); } @override void dispose() { _controller.dispose(); super.dispose(); } /// ์ง€๊ธˆ ํฐ ํƒ€์ผ์— ๋ณด์—ฌ์ค„ ํ…œํ”Œ๋ฆฟ์„ ๊ณ ๋ฅธ๋‹ค. ์ง์ ‘ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๋ˆ„๋ฅธ ๊ฒŒ ์žˆ์œผ๋ฉด ๊ทธ๊ฑธ, /// ์—†์œผ๋ฉด ์‹ค์ œ ์žฅ์ฐฉ ์ค‘์ธ ํ…œํ”Œ๋ฆฟ์„, ๊ทธ๋งˆ์ €๋„ ์—†์œผ๋ฉด 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; } /// ๐ŸŽจ ์ง€๊ธˆ "์ฒดํ—˜ํ•˜๊ธฐ/๋ฏธ๋ฆฌ๋ณด๊ธฐ" ์ค‘์ธ ํ…Œ๋งˆ๋กœ ํ™”๋ฉด ์ „์ฒด(๋ฐฐ๊ฒฝ/ํƒ€์ผ/๋ฒ„ํŠผ)๋ฅผ ๋ฌผ๋“ค์ธ๋‹ค. /// ์•„๋ฌด๊ฒƒ๋„ ์ง์ ‘ ๊ณ ๋ฅด์ง€ ์•Š์•˜์œผ๋ฉด ์‹ค์ œ ์žฅ์ฐฉ๋œ ํ…Œ๋งˆ๊ฐ€ ๊ทธ๋Œ€๋กœ ๋‚˜์˜ค๊ณ , "๊ธฐ๋ณธ ๋ชจ์Šต"์„ /// ๊ณ ๋ฅด๋ฉด null์ด ๋˜์–ด ํ™”๋ฉด์ด ์›๋ž˜ ๋ชจ์Šต์œผ๋กœ ๋Œ์•„๊ฐ„๋‹ค - ์ฒดํ—˜ํ•˜๊ธฐ๋ฅผ ๋ˆ„๋ฅด๋ฉด ๋ฐ”๋กœ /// ์ปค์Šคํ…€ ํ™”๋ฉด ์ „์ฒด์— ๋ฐ˜์˜๋˜๋Š” ํšจ๊ณผ๋ฅผ _resolvePreviewTemplate() ํ•˜๋‚˜๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. ProfileTheme? get _displayTheme { final preview = _resolvePreviewTemplate(); return preview == null ? null : profileThemes[preview.code]; } 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) _syncGlobalTheme(templateId); if (!mounted) return; AppNotice.show( context, message, color: success ? AppPalette.approved : AppPalette.danger, ); } /// ๐Ÿ’ก ์ปค์Šคํ…€์ฐฝ ์•ˆ์—์„œ์˜ "๋ฏธ๋ฆฌ๋ณด๊ธฐ"์™€ ๋‹ฌ๋ฆฌ, ์‹ค์ œ๋กœ ์žฅ์ฐฉ์ด ๋๋‚œ ๋’ค์—๋Š” ์•ฑ ์ „์ฒด /// (TitlePill, ๋‹ค๋ฅธ ํ™”๋ฉด์˜ ๋ฐฐ๊ฒฝ/์นด๋“œ์ƒ‰)๋„ ๊ฐ™์ด ๋ฐ”๋€Œ๋„๋ก ์ „์—ญ์— ๋ฐ˜์˜ํ•œ๋‹ค. void _syncGlobalTheme(int? equippedTemplateId) { if (equippedTemplateId == null) { applyGlobalProfileTheme(null); return; } for (final t in _controller.templates) { if (t.id == equippedTemplateId) { applyGlobalProfileTheme(profileThemes[t.code]); return; } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _displayTheme?.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: _displayTheme?.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: _displayTheme, 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: _displayTheme, 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('๊ตฌ๋งคํ•˜๊ธฐ'), ), ), ], ), ], ), ), ), ); } }