백엔드에 새로 생긴 /api/shop/* 엔드포인트를 붙여서 프로필 커스텀 화면을 실제 상점으로 채웠다. 랭킹/친구창 배경, 출석/그룹창 표시 카테고리는 와이어프레임 자리만 잡아둔 준비중 상태 - 1단계는 기본 프로필 테마만 동작. 장착한 테마는 메인 대시보드 프로필 카드 배경에 바로 반영된다. 학생이 직접 템플릿을 만들어 파는 기능은 이후 단계로 보류.
420 lines
12 KiB
Dart
420 lines
12 KiB
Dart
// 🎨 프로필 커스텀 화면 (UI 전용). 공부시간으로 적립한 캐시로 프로필 카드 테마를
|
|
// 구매/장착하는 상점. 서버 통신/상태는 lib/function/profile_shop_controller.dart가 담당한다.
|
|
import 'package:flutter/material.dart';
|
|
import '../function/profile_shop_controller.dart';
|
|
import '../theme/app_palette.dart';
|
|
import 'app_notice.dart';
|
|
import 'title_pill.dart';
|
|
|
|
class ProfileCustomizeScreen extends StatefulWidget {
|
|
final String studentId;
|
|
final String studentName;
|
|
|
|
const ProfileCustomizeScreen({
|
|
super.key,
|
|
required this.studentId,
|
|
required this.studentName,
|
|
});
|
|
|
|
@override
|
|
State<ProfileCustomizeScreen> createState() => _ProfileCustomizeScreenState();
|
|
}
|
|
|
|
class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
|
|
final ProfileShopController _controller = ProfileShopController();
|
|
|
|
// 🚧 첫 버전은 "기본 프로필 꾸미기"만 실제로 동작한다. 나머지 두 카테고리는
|
|
// 와이어프레임 자리만 잡아둔 준비중 상태.
|
|
String _selectedCategory = 'basic_profile';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.loadCatalog(widget.studentId);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _handlePurchase(ShopTemplate template) async {
|
|
final (success, message) = await _controller.purchase(
|
|
widget.studentId,
|
|
template.id,
|
|
);
|
|
if (!mounted) return;
|
|
AppNotice.show(
|
|
context,
|
|
message,
|
|
color: success ? AppPalette.success : AppPalette.danger,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleEquip(int? templateId) async {
|
|
final (success, message) = await _controller.equip(
|
|
widget.studentId,
|
|
templateId,
|
|
);
|
|
if (!mounted) return;
|
|
AppNotice.show(
|
|
context,
|
|
message,
|
|
color: success ? AppPalette.approved : AppPalette.danger,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppPalette.mist,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: AppPalette.ink,
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
title: const TitlePill('프로필 꾸미기'),
|
|
),
|
|
body: ListenableBuilder(
|
|
listenable: _controller,
|
|
builder: (context, _) {
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 720),
|
|
child: RefreshIndicator(
|
|
onRefresh: () => _controller.loadCatalog(widget.studentId),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
_buildCashBalance(),
|
|
const SizedBox(height: 20),
|
|
_buildCategoryTiles(),
|
|
const SizedBox(height: 24),
|
|
if (_controller.isLoading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 40),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else
|
|
_buildTemplateGrid(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCashBalance() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.ink,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.savings_rounded, color: Colors.white, size: 22),
|
|
const SizedBox(width: 10),
|
|
const Text(
|
|
'보유 캐시',
|
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'${_controller.cashBalance} 캐시',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryTiles() {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: _CategoryTile(
|
|
title: '기본 프로필 꾸미기',
|
|
icon: Icons.person_rounded,
|
|
selected: _selectedCategory == 'basic_profile',
|
|
enabled: true,
|
|
onTap: () => setState(() => _selectedCategory = 'basic_profile'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 4,
|
|
child: Column(
|
|
children: [
|
|
_CategoryTile(
|
|
title: '랭킹 · 친구창 배경',
|
|
subtitle: '준비중',
|
|
icon: Icons.leaderboard_rounded,
|
|
selected: false,
|
|
enabled: false,
|
|
onTap: null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_CategoryTile(
|
|
title: '출석 · 그룹창 표시',
|
|
subtitle: '준비중',
|
|
icon: Icons.groups_rounded,
|
|
selected: false,
|
|
enabled: false,
|
|
onTap: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTemplateGrid() {
|
|
final templates = _controller.templates
|
|
.where((t) => t.category == _selectedCategory)
|
|
.toList();
|
|
final hasEquippedTemplate = templates.any((t) => t.equipped);
|
|
|
|
return GridView.count(
|
|
crossAxisCount: 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 1.15,
|
|
children: [
|
|
_DefaultTemplateTile(
|
|
equipped: !hasEquippedTemplate,
|
|
onEquip: () => _handleEquip(null),
|
|
),
|
|
for (final template in templates)
|
|
_TemplateTile(
|
|
template: template,
|
|
canAfford: _controller.cashBalance >= template.price,
|
|
onPurchase: () => _handlePurchase(template),
|
|
onEquip: () => _handleEquip(template.id),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoryTile extends StatelessWidget {
|
|
final String title;
|
|
final String? subtitle;
|
|
final IconData icon;
|
|
final bool selected;
|
|
final bool enabled;
|
|
final VoidCallback? onTap;
|
|
|
|
const _CategoryTile({
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.enabled,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: selected
|
|
? AppPalette.ink
|
|
: (enabled ? AppPalette.paper : AppPalette.linen),
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: selected ? AppPalette.ink : AppPalette.sage,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 26,
|
|
color: selected
|
|
? Colors.white
|
|
: (enabled ? AppPalette.ink : Colors.grey),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
color: selected
|
|
? Colors.white
|
|
: (enabled ? AppPalette.ink : Colors.grey[600]),
|
|
),
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle!,
|
|
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DefaultTemplateTile extends StatelessWidget {
|
|
final bool equipped;
|
|
final VoidCallback onEquip;
|
|
|
|
const _DefaultTemplateTile({required this.equipped, required this.onEquip});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
child: const Text('장착중'),
|
|
)
|
|
: OutlinedButton(onPressed: onEquip, child: const Text('장착하기')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TemplateTile extends StatelessWidget {
|
|
final ShopTemplate template;
|
|
final bool canAfford;
|
|
final VoidCallback onPurchase;
|
|
final VoidCallback onEquip;
|
|
|
|
const _TemplateTile({
|
|
required this.template,
|
|
required this.canAfford,
|
|
required this.onPurchase,
|
|
required this.onEquip,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
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: 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('구매하기'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|