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