From 6b53214913b5d1554eb5ddcecd366c3c7335a52f Mon Sep 17 00:00:00 2001 From: sihoo Date: Thu, 1 Oct 2026 04:16:22 +0000 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=BE=B8?= =?UTF-8?q?=EB=AF=B8=EA=B8=B0=20=EC=83=81=EC=A0=90=EC=97=90=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20=EC=A0=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이 그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다. 미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도 직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다. --- lib/ui/profile_customize_screen.dart | 315 ++++++++++++++++++--------- 1 file changed, 217 insertions(+), 98 deletions(-) diff --git a/lib/ui/profile_customize_screen.dart b/lib/ui/profile_customize_screen.dart index 498c2df..9105a92 100644 --- a/lib/ui/profile_customize_screen.dart +++ b/lib/ui/profile_customize_screen.dart @@ -27,6 +27,10 @@ class _ProfileCustomizeScreenState extends State { // 와이어프레임 자리만 잡아둔 준비중 상태. String _selectedCategory = 'basic_profile'; + // 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태. + // null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중. + int? _previewTemplateId; + @override void initState() { super.initState(); @@ -39,7 +43,23 @@ class _ProfileCustomizeScreenState extends State { 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; + } + Future _handlePurchase(ShopTemplate template) async { + setState(() => _previewTemplateId = template.id); final (success, message) = await _controller.purchase( widget.studentId, template.id, @@ -53,6 +73,7 @@ class _ProfileCustomizeScreenState extends State { } Future _handleEquip(int? templateId) async { + setState(() => _previewTemplateId = templateId ?? -1); final (success, message) = await _controller.equip( widget.studentId, templateId, @@ -138,6 +159,7 @@ class _ProfileCustomizeScreenState extends State { } Widget _buildCategoryTiles() { + final previewTemplate = _resolvePreviewTemplate(); // 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에 // stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다. return SizedBox( @@ -153,6 +175,8 @@ class _ProfileCustomizeScreenState extends State { selected: _selectedCategory == 'basic_profile', enabled: true, onTap: () => setState(() => _selectedCategory = 'basic_profile'), + previewPrimary: previewTemplate?.primaryColor, + previewSecondary: previewTemplate?.secondaryColor, ), ), const SizedBox(width: 12), @@ -194,6 +218,7 @@ class _ProfileCustomizeScreenState extends State { .where((t) => t.category == _selectedCategory) .toList(); final hasEquippedTemplate = templates.any((t) => t.equipped); + final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중. return GridView.count( crossAxisCount: 2, @@ -205,14 +230,18 @@ class _ProfileCustomizeScreenState extends State { children: [ _DefaultTemplateTile( equipped: !hasEquippedTemplate, + previewing: previewId == null, onEquip: () => _handleEquip(null), + onPreview: () => setState(() => _previewTemplateId = -1), ), for (final template in templates) _TemplateTile( template: template, canAfford: _controller.cashBalance >= template.price, + previewing: previewId == template.id, onPurchase: () => _handlePurchase(template), onEquip: () => _handleEquip(template.id), + onPreview: () => setState(() => _previewTemplateId = template.id), ), ], ); @@ -226,6 +255,9 @@ class _CategoryTile extends StatelessWidget { final bool selected; final bool enabled; final VoidCallback? onTap; + // 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용). + final Color? previewPrimary; + final Color? previewSecondary; const _CategoryTile({ required this.title, @@ -234,14 +266,19 @@ class _CategoryTile extends StatelessWidget { 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: selected - ? AppPalette.ink - : (enabled ? AppPalette.paper : AppPalette.linen), + color: hasPreview + ? Colors.transparent + : (selected + ? AppPalette.ink + : (enabled ? AppPalette.paper : AppPalette.linen)), borderRadius: BorderRadius.circular(20), child: InkWell( borderRadius: BorderRadius.circular(20), @@ -250,6 +287,13 @@ class _CategoryTile extends StatelessWidget { 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: selected ? AppPalette.ink : AppPalette.sage, ), @@ -298,54 +342,93 @@ class _CategoryTile extends StatelessWidget { class _DefaultTemplateTile extends StatelessWidget { final bool equipped; + final bool previewing; final VoidCallback onEquip; + final VoidCallback onPreview; - const _DefaultTemplateTile({required this.equipped, required this.onEquip}); + const _DefaultTemplateTile({ + required this.equipped, + required this.previewing, + required this.onEquip, + required this.onPreview, + }); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppPalette.paper, + final borderColor = equipped + ? AppPalette.approved + : (previewing ? AppPalette.ink : AppPalette.sage); + final borderWidth = equipped || previewing ? 2.0 : 1.0; + + return Material( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(18), + child: InkWell( borderRadius: BorderRadius.circular(18), - border: Border.all( - color: equipped ? AppPalette.approved : AppPalette.sage, - width: equipped ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: AppPalette.ink, - borderRadius: BorderRadius.circular(12), + 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), - const Text( - '기본 모습', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), - ), - 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), + const SizedBox(height: 10), + Row( + children: [ + const Expanded( + child: Text( + '기본 모습', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), ), - child: const Text('장착중'), - ) - : OutlinedButton(onPressed: onEquip, child: const Text('장착하기')), + ), + if (previewing && !equipped) + const Text( + '미리보기', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + 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, + child: const Text('장착하기'), + ), + ), + ], ), - ], + ), ), ); } @@ -354,79 +437,115 @@ class _DefaultTemplateTile extends StatelessWidget { class _TemplateTile extends StatelessWidget { final ShopTemplate template; final bool canAfford; + final bool previewing; final VoidCallback onPurchase; final VoidCallback onEquip; + final VoidCallback onPreview; const _TemplateTile({ required this.template, required this.canAfford, + required this.previewing, required this.onPurchase, required this.onEquip, + required this.onPreview, }); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppPalette.paper, + final borderColor = template.equipped + ? AppPalette.approved + : (previewing ? AppPalette.ink : AppPalette.sage); + final borderWidth = template.equipped || previewing ? 2.0 : 1.0; + + return Material( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(18), + child: InkWell( borderRadius: BorderRadius.circular(18), - border: Border.all( - color: template.equipped ? AppPalette.approved : AppPalette.sage, - width: template.equipped ? 2 : 1, - ), - ), - 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), - ), - ), + onTap: onPreview, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: borderColor, width: borderWidth), ), - const SizedBox(height: 10), - Text( - template.name, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), - ), - const SizedBox(height: 2), - Text( - '${template.price} 캐시', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: template.equipped - ? OutlinedButton( - onPressed: null, - style: OutlinedButton.styleFrom( - foregroundColor: AppPalette.approved, - side: const BorderSide(color: AppPalette.approved), + 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], ), - child: const Text('장착중'), - ) - : template.owned - ? OutlinedButton(onPressed: onEquip, child: const Text('장착하기')) - : ElevatedButton( - onPressed: canAfford ? onPurchase : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppPalette.ink, - foregroundColor: Colors.white, - disabledBackgroundColor: AppPalette.sage, - ), - child: const Text('구매하기'), + borderRadius: BorderRadius.circular(12), ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + template.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + if (previewing && !template.equipped) + const Text( + '미리보기', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${template.price} 캐시', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: template.equipped + ? OutlinedButton( + onPressed: null, + style: OutlinedButton.styleFrom( + foregroundColor: AppPalette.approved, + side: const BorderSide(color: AppPalette.approved), + ), + child: const Text('장착중'), + ) + : template.owned + ? OutlinedButton( + onPressed: onEquip, + child: const Text('장착하기'), + ) + : ElevatedButton( + onPressed: canAfford ? onPurchase : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: Colors.white, + disabledBackgroundColor: AppPalette.sage, + ), + child: const Text('구매하기'), + ), + ), + ], ), - ], + ), ), ); }