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

템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이
그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다.
미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도
직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다.
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';
// 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태.
// null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중.
int? _previewTemplateId;
@override
void initState() {
super.initState();
@@ -39,7 +43,23 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
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 {
setState(() => _previewTemplateId = template.id);
final (success, message) = await _controller.purchase(
widget.studentId,
template.id,
@@ -53,6 +73,7 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
}
Future<void> _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<ProfileCustomizeScreen> {
}
Widget _buildCategoryTiles() {
final previewTemplate = _resolvePreviewTemplate();
// 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에
// stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다.
return SizedBox(
@@ -153,6 +175,8 @@ class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
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<ProfileCustomizeScreen> {
.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<ProfileCustomizeScreen> {
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
color: hasPreview
? Colors.transparent
: (selected
? AppPalette.ink
: (enabled ? AppPalette.paper : AppPalette.linen),
: (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,21 +342,35 @@ 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(
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),
border: Border.all(
color: equipped ? AppPalette.approved : AppPalette.sage,
width: equipped ? 2 : 1,
),
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,
@@ -327,9 +385,29 @@ class _DefaultTemplateTile extends StatelessWidget {
),
),
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),
SizedBox(
@@ -343,10 +421,15 @@ class _DefaultTemplateTile extends StatelessWidget {
),
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 {
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(
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),
border: Border.all(
color: template.equipped ? AppPalette.approved : AppPalette.sage,
width: template.equipped ? 2 : 1,
),
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,
@@ -393,9 +487,29 @@ class _TemplateTile extends StatelessWidget {
),
),
const SizedBox(height: 10),
Text(
Row(
children: [
Expanded(
child: Text(
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),
Text(
@@ -415,7 +529,10 @@ class _TemplateTile extends StatelessWidget {
child: const Text('장착중'),
)
: template.owned
? OutlinedButton(onPressed: onEquip, child: const Text('장착하기'))
? OutlinedButton(
onPressed: onEquip,
child: const Text('장착하기'),
)
: ElevatedButton(
onPressed: canAfford ? onPurchase : null,
style: ElevatedButton.styleFrom(
@@ -428,6 +545,8 @@ class _TemplateTile extends StatelessWidget {
),
],
),
),
),
);
}
}