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

템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이
그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다.
미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도
직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다.
This commit is contained in:
2026-10-01 04:16:22 +00:00
parent 13a86ec95e
commit 6b53214913
+217 -98
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
? AppPalette.ink ? Colors.transparent
: (enabled ? AppPalette.paper : AppPalette.linen), : (selected
? AppPalette.ink
: (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,54 +342,93 @@ 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);
color: AppPalette.paper, final borderWidth = equipped || previewing ? 2.0 : 1.0;
return Material(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all( onTap: onPreview,
color: equipped ? AppPalette.approved : AppPalette.sage, child: Container(
width: equipped ? 2 : 1, padding: const EdgeInsets.all(14),
), decoration: BoxDecoration(
), borderRadius: BorderRadius.circular(18),
child: Column( border: Border.all(color: borderColor, width: borderWidth),
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: Container( children: [
width: double.infinity, Expanded(
decoration: BoxDecoration( child: Container(
color: AppPalette.ink, width: double.infinity,
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(12),
),
),
), ),
), const SizedBox(height: 10),
), Row(
const SizedBox(height: 10), children: [
const Text( const Expanded(
'기본 모습', child: Text(
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), '기본 모습',
), maxLines: 1,
const SizedBox(height: 8), overflow: TextOverflow.ellipsis,
SizedBox( style: TextStyle(
width: double.infinity, fontWeight: FontWeight.bold,
child: equipped fontSize: 13,
? OutlinedButton( ),
onPressed: null,
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.approved,
side: const BorderSide(color: AppPalette.approved),
), ),
child: const Text('장착중'), ),
) if (previewing && !equipped)
: OutlinedButton(onPressed: onEquip, child: const Text('장착하기')), 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 { 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);
color: AppPalette.paper, final borderWidth = template.equipped || previewing ? 2.0 : 1.0;
return Material(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: Border.all( onTap: onPreview,
color: template.equipped ? AppPalette.approved : AppPalette.sage, child: Container(
width: template.equipped ? 2 : 1, padding: const EdgeInsets.all(14),
), decoration: BoxDecoration(
), borderRadius: BorderRadius.circular(18),
child: Column( border: Border.all(color: borderColor, width: borderWidth),
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), child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
template.name, children: [
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), Expanded(
), child: Container(
const SizedBox(height: 2), width: double.infinity,
Text( decoration: BoxDecoration(
'${template.price} 캐시', gradient: LinearGradient(
style: TextStyle(fontSize: 12, color: Colors.grey[600]), begin: Alignment.topLeft,
), end: Alignment.bottomRight,
const SizedBox(height: 8), colors: [template.primaryColor, template.secondaryColor],
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('장착중'), borderRadius: BorderRadius.circular(12),
)
: 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('구매하기'),
), ),
),
),
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('구매하기'),
),
),
],
), ),
], ),
), ),
); );
} }