프로필 꾸미기 상점에 구매 전 미리보기 기능 추가
템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이 그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다. 미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도 직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다.
This commit is contained in:
@@ -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 {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user