프로필 꾸미기 상점에 구매 전 미리보기 기능 추가

템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이
그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다.
미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도
직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다.
This commit is contained in:
2026-10-01 04:16:22 +00:00
parent 13a86ec95e
commit 6b53214913
+142 -23
View File
@@ -27,6 +27,10 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
// 와이어프레임 자리만 잡아둔 준비중 상태. // 와이어프레임 자리만 잡아둔 준비중 상태.
String _selectedCategory = 'basic_profile'; String _selectedCategory = 'basic_profile';
// 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태.
// null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중.
int? _previewTemplateId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -39,7 +43,23 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
super.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;
}
Future<void> _handlePurchase(ShopTemplate template) async { Future<void> _handlePurchase(ShopTemplate template) async {
setState(() => _previewTemplateId = template.id);
final (success, message) = await _controller.purchase( final (success, message) = await _controller.purchase(
widget.studentId, widget.studentId,
template.id, template.id,
@@ -53,6 +73,7 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
} }
Future<void> _handleEquip(int? templateId) async { Future<void> _handleEquip(int? templateId) async {
setState(() => _previewTemplateId = templateId ?? -1);
final (success, message) = await _controller.equip( final (success, message) = await _controller.equip(
widget.studentId, widget.studentId,
templateId, templateId,
@@ -138,6 +159,7 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
} }
Widget _buildCategoryTiles() { Widget _buildCategoryTiles() {
final previewTemplate = _resolvePreviewTemplate();
// 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에 // 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에
// stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다. // stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다.
return SizedBox( return SizedBox(
@@ -153,6 +175,8 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
selected: _selectedCategory == 'basic_profile', selected: _selectedCategory == 'basic_profile',
enabled: true, enabled: true,
onTap: () => setState(() => _selectedCategory = 'basic_profile'), onTap: () => setState(() => _selectedCategory = 'basic_profile'),
previewPrimary: previewTemplate?.primaryColor,
previewSecondary: previewTemplate?.secondaryColor,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -194,6 +218,7 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
.where((t) => t.category == _selectedCategory) .where((t) => t.category == _selectedCategory)
.toList(); .toList();
final hasEquippedTemplate = templates.any((t) => t.equipped); final hasEquippedTemplate = templates.any((t) => t.equipped);
final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중.
return GridView.count( return GridView.count(
crossAxisCount: 2, crossAxisCount: 2,
@@ -205,14 +230,18 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
children: [ children: [
_DefaultTemplateTile( _DefaultTemplateTile(
equipped: !hasEquippedTemplate, equipped: !hasEquippedTemplate,
previewing: previewId == null,
onEquip: () => _handleEquip(null), onEquip: () => _handleEquip(null),
onPreview: () => setState(() => _previewTemplateId = -1),
), ),
for (final template in templates) for (final template in templates)
_TemplateTile( _TemplateTile(
template: template, template: template,
canAfford: _controller.cashBalance >= template.price, canAfford: _controller.cashBalance >= template.price,
previewing: previewId == template.id,
onPurchase: () => _handlePurchase(template), onPurchase: () => _handlePurchase(template),
onEquip: () => _handleEquip(template.id), onEquip: () => _handleEquip(template.id),
onPreview: () => setState(() => _previewTemplateId = template.id),
), ),
], ],
); );
@@ -226,6 +255,9 @@ class _CategoryTile extends StatelessWidget {
final bool selected; final bool selected;
final bool enabled; final bool enabled;
final VoidCallback? onTap; final VoidCallback? onTap;
// 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용).
final Color? previewPrimary;
final Color? previewSecondary;
const _CategoryTile({ const _CategoryTile({
required this.title, required this.title,
@@ -234,14 +266,19 @@ class _CategoryTile extends StatelessWidget {
required this.selected, required this.selected,
required this.enabled, required this.enabled,
required this.onTap, required this.onTap,
this.previewPrimary,
this.previewSecondary,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool hasPreview = selected && previewPrimary != null;
return Material( return Material(
color: selected color: hasPreview
? Colors.transparent
: (selected
? AppPalette.ink ? AppPalette.ink
: (enabled ? AppPalette.paper : AppPalette.linen), : (enabled ? AppPalette.paper : AppPalette.linen)),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -250,6 +287,13 @@ class _CategoryTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
gradient: hasPreview
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [previewPrimary!, previewSecondary!],
)
: null,
border: Border.all( border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage, color: selected ? AppPalette.ink : AppPalette.sage,
), ),
@@ -298,21 +342,35 @@ class _CategoryTile extends StatelessWidget {
class _DefaultTemplateTile extends StatelessWidget { class _DefaultTemplateTile extends StatelessWidget {
final bool equipped; final bool equipped;
final bool previewing;
final VoidCallback onEquip; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final borderColor = equipped
padding: const EdgeInsets.all(14), ? AppPalette.approved
decoration: BoxDecoration( : (previewing ? AppPalette.ink : AppPalette.sage);
final borderWidth = equipped || previewing ? 2.0 : 1.0;
return Material(
color: AppPalette.paper, color: AppPalette.paper,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all( child: InkWell(
color: equipped ? AppPalette.approved : AppPalette.sage, borderRadius: BorderRadius.circular(18),
width: equipped ? 2 : 1, onTap: onPreview,
), child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: borderColor, width: borderWidth),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -327,9 +385,29 @@ class _DefaultTemplateTile extends StatelessWidget {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Text( Row(
children: [
const Expanded(
child: Text(
'기본 모습', '기본 모습',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
if (previewing && !equipped)
const Text(
'미리보기',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
@@ -343,10 +421,15 @@ class _DefaultTemplateTile extends StatelessWidget {
), ),
child: const Text('장착중'), child: const Text('장착중'),
) )
: OutlinedButton(onPressed: onEquip, child: const Text('장착하기')), : OutlinedButton(
onPressed: onEquip,
child: const Text('장착하기'),
),
), ),
], ],
), ),
),
),
); );
} }
} }
@@ -354,27 +437,38 @@ class _DefaultTemplateTile extends StatelessWidget {
class _TemplateTile extends StatelessWidget { class _TemplateTile extends StatelessWidget {
final ShopTemplate template; final ShopTemplate template;
final bool canAfford; final bool canAfford;
final bool previewing;
final VoidCallback onPurchase; final VoidCallback onPurchase;
final VoidCallback onEquip; final VoidCallback onEquip;
final VoidCallback onPreview;
const _TemplateTile({ const _TemplateTile({
required this.template, required this.template,
required this.canAfford, required this.canAfford,
required this.previewing,
required this.onPurchase, required this.onPurchase,
required this.onEquip, required this.onEquip,
required this.onPreview,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final borderColor = template.equipped
padding: const EdgeInsets.all(14), ? AppPalette.approved
decoration: BoxDecoration( : (previewing ? AppPalette.ink : AppPalette.sage);
final borderWidth = template.equipped || previewing ? 2.0 : 1.0;
return Material(
color: AppPalette.paper, color: AppPalette.paper,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all( child: InkWell(
color: template.equipped ? AppPalette.approved : AppPalette.sage, borderRadius: BorderRadius.circular(18),
width: template.equipped ? 2 : 1, onTap: onPreview,
), child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: borderColor, width: borderWidth),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -393,9 +487,29 @@ class _TemplateTile extends StatelessWidget {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Row(
children: [
Expanded(
child: Text(
template.name, template.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), 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), const SizedBox(height: 2),
Text( Text(
@@ -415,7 +529,10 @@ class _TemplateTile extends StatelessWidget {
child: const Text('장착중'), child: const Text('장착중'),
) )
: template.owned : template.owned
? OutlinedButton(onPressed: onEquip, child: const Text('장착하기')) ? OutlinedButton(
onPressed: onEquip,
child: const Text('장착하기'),
)
: ElevatedButton( : ElevatedButton(
onPressed: canAfford ? onPurchase : null, onPressed: canAfford ? onPurchase : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -428,6 +545,8 @@ class _TemplateTile extends StatelessWidget {
), ),
], ],
), ),
),
),
); );
} }
} }