From 705c0c086a3bd0d749a7fb6ee2e604389269567e Mon Sep 17 00:00:00 2001 From: sihoo Date: Wed, 30 Sep 2026 11:17:56 +0000 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=BE=B8?= =?UTF-8?q?=EB=AF=B8=EA=B8=B0=20=EC=83=81=EC=A0=90=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(1=EB=8B=A8=EA=B3=84:=20=EA=B8=B0=EB=B3=B8=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=85=8C=EB=A7=88)=20-=20=EA=B3=B5=EB=B6=80?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=BA=90=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=8D=A8=EC=84=9C=20=EA=B5=AC=EB=A7=A4/=EC=9E=A5=EC=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드에 새로 생긴 /api/shop/* 엔드포인트를 붙여서 프로필 커스텀 화면을 실제 상점으로 채웠다. 랭킹/친구창 배경, 출석/그룹창 표시 카테고리는 와이어프레임 자리만 잡아둔 준비중 상태 - 1단계는 기본 프로필 테마만 동작. 장착한 테마는 메인 대시보드 프로필 카드 배경에 바로 반영된다. 학생이 직접 템플릿을 만들어 파는 기능은 이후 단계로 보류. --- lib/function/profile_shop_controller.dart | 150 ++++++++ lib/ui/main_dashboard.dart | 51 ++- lib/ui/profile_customize_screen.dart | 401 +++++++++++++++++++++- 3 files changed, 574 insertions(+), 28 deletions(-) create mode 100644 lib/function/profile_shop_controller.dart diff --git a/lib/function/profile_shop_controller.dart b/lib/function/profile_shop_controller.dart new file mode 100644 index 0000000..a830788 --- /dev/null +++ b/lib/function/profile_shop_controller.dart @@ -0,0 +1,150 @@ +// 🛍️ 프로필 꾸미기 상점 기능(서버 통신/상태) 담당 컨트롤러. +// UI는 lib/ui/profile_customize_screen.dart가 담당한다. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Color; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class ShopTemplate { + final int id; + final String category; + final String name; + final int price; + final Color primaryColor; + final Color secondaryColor; + final bool owned; + final bool equipped; + + ShopTemplate({ + required this.id, + required this.category, + required this.name, + required this.price, + required this.primaryColor, + required this.secondaryColor, + required this.owned, + required this.equipped, + }); + + factory ShopTemplate.fromJson(Map json) { + return ShopTemplate( + id: json['id'] as int, + category: json['category'] as String, + name: json['name'] as String, + price: json['price'] as int, + primaryColor: _parseColor(json['primaryColor'] as String), + secondaryColor: _parseColor(json['secondaryColor'] as String), + owned: json['owned'] == true, + equipped: json['equipped'] == true, + ); + } +} + +Color _parseColor(String hex) { + return Color(int.parse(hex.substring(2), radix: 16)); +} + +class ProfileShopController extends ChangeNotifier { + bool _isLoading = false; + bool get isLoading => _isLoading; + + int cashBalance = 0; + List templates = []; + + void _safeNotify() { + if (hasListeners) notifyListeners(); + } + + Future loadCatalog(String studentId) async { + _isLoading = true; + _safeNotify(); + try { + final url = Uri.parse( + '$baseUrl/api/shop/catalog?studentId=${Uri.encodeComponent(studentId)}', + ); + final response = await http.get(url); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + cashBalance = (data['cashBalance'] as num?)?.toInt() ?? 0; + templates = ((data['templates'] as List?) ?? []) + .map((t) => ShopTemplate.fromJson(t as Map)) + .toList(); + } + } catch (_) { + // 네트워크 에러여도 화면은 빈 상태로라도 그려지게 조용히 넘어간다. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> purchase( + String studentId, + int templateId, + ) async { + try { + final url = Uri.parse('$baseUrl/api/shop/purchase'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'studentId': studentId, 'templateId': templateId}), + ); + final data = jsonDecode(response.body); + final success = data['status'] == 'success'; + if (success) { + await loadCatalog(studentId); + } + return (success, '${data['message'] ?? (success ? '구매 완료!' : '구매 실패')}'); + } catch (e) { + return (false, '서버와 연결할 수 없습니다. (네트워크 에러)'); + } + } + + Future<(bool success, String message)> equip( + String studentId, + int? templateId, + ) async { + try { + final url = Uri.parse('$baseUrl/api/shop/equip'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'studentId': studentId, 'templateId': templateId}), + ); + final data = jsonDecode(response.body); + final success = data['status'] == 'success'; + if (success) { + await loadCatalog(studentId); + } + return (success, '${data['message'] ?? (success ? '장착 완료' : '장착 실패')}'); + } catch (e) { + return (false, '서버와 연결할 수 없습니다. (네트워크 에러)'); + } + } + + /// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도 + /// 현재 장착 중인 테마 색상만 가볍게 조회한다. + static Future<(Color? primary, Color? secondary)> fetchEquippedColors( + String studentId, + ) async { + try { + final url = Uri.parse( + '$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}', + ); + final response = await http.get(url); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['equipped'] == true) { + return ( + _parseColor(data['primaryColor'] as String), + _parseColor(data['secondaryColor'] as String), + ); + } + } + } catch (_) { + // 실패하면 그냥 기본 모습으로 보여준다. + } + return (null, null); + } +} diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index 5847070..a790c59 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../function/dashboard_layout_controller.dart'; +import '../function/profile_shop_controller.dart'; import '../function/session_store.dart'; import '../function/student_dashboard_controller.dart'; import '../models/dashboard_tile_layout.dart'; @@ -79,6 +80,23 @@ class _MainDashboardState extends State { if (kTileEditingEnabled) { _layout.load(_tileSpecs().map((t) => t.id).toList()); } + if (_isStudent) { + _loadEquippedTheme(); + } + } + + // 🛍️ 프로필 꾸미기 상점에서 장착한 테마 색상 (없으면 기본 모습 그대로). + Color? _equippedPrimary; + Color? _equippedSecondary; + + Future _loadEquippedTheme() async { + final (primary, secondary) = + await ProfileShopController.fetchEquippedColors(_displayId); + if (!mounted) return; + setState(() { + _equippedPrimary = primary; + _equippedSecondary = secondary; + }); } @override @@ -645,11 +663,22 @@ class _MainDashboardState extends State { vertical: 18, ), decoration: BoxDecoration( - color: theme.color, + color: _equippedPrimary == null ? theme.color : null, + gradient: _equippedPrimary == null + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _equippedPrimary!, + _equippedSecondary!, + ], + ), borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( - color: theme.color.withValues(alpha: 0.25), + color: (_equippedPrimary ?? theme.color) + .withValues(alpha: 0.25), blurRadius: 16, offset: const Offset(0, 6), ), @@ -715,13 +744,17 @@ class _MainDashboardState extends State { elevation: 2, child: InkWell( customBorder: const CircleBorder(), - onTap: () => pushLaunchpad( - context, - (context) => ProfileCustomizeScreen( - studentId: _displayId, - studentName: _displayName, - ), - ), + onTap: () async { + await pushLaunchpad( + context, + (context) => ProfileCustomizeScreen( + studentId: _displayId, + studentName: _displayName, + ), + ); + // 상점에서 테마를 바꾸고 돌아왔을 수 있으니 다시 불러온다. + if (_isStudent) _loadEquippedTheme(); + }, child: const Padding( padding: EdgeInsets.all(8), child: Icon( diff --git a/lib/ui/profile_customize_screen.dart b/lib/ui/profile_customize_screen.dart index f446b50..5e4aec5 100644 --- a/lib/ui/profile_customize_screen.dart +++ b/lib/ui/profile_customize_screen.dart @@ -1,10 +1,12 @@ -// 🎨 프로필 커스텀 화면 (UI 전용). 아직은 자리만 잡아둔 빈 화면 - 프로필 꾸미기 -// 관련 기능들이 여기에 하나씩 추가될 예정이다. +// 🎨 프로필 커스텀 화면 (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 StatelessWidget { +class ProfileCustomizeScreen extends StatefulWidget { final String studentId; final String studentName; @@ -14,6 +16,55 @@ class ProfileCustomizeScreen extends StatelessWidget { required this.studentName, }); + @override + State createState() => _ProfileCustomizeScreenState(); +} + +class _ProfileCustomizeScreenState extends State { + 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 _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 _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( @@ -25,28 +76,204 @@ class ProfileCustomizeScreen extends StatelessWidget { centerTitle: true, title: const TitlePill('프로필 꾸미기'), ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), + 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( - mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.auto_awesome_rounded, - size: 48, - color: AppPalette.ink, + _CategoryTile( + title: '랭킹 · 친구창 배경', + subtitle: '준비중', + icon: Icons.leaderboard_rounded, + selected: false, + enabled: false, + onTap: null, ), - const SizedBox(height: 16), - const Text( - '곧 다양한 프로필 커스텀 기능이 추가될 예정이에요.', - textAlign: TextAlign.center, - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + const SizedBox(height: 12), + _CategoryTile( + title: '출석 · 그룹창 표시', + subtitle: '준비중', + icon: Icons.groups_rounded, + selected: false, + enabled: false, + onTap: null, ), - const SizedBox(height: 6), + ], + ), + ), + ], + ); + } + + 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( - '$studentName ($studentId)', - style: const TextStyle(color: Colors.grey), + 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]), + ), + ], ], ), ), @@ -54,3 +281,139 @@ class ProfileCustomizeScreen extends StatelessWidget { ); } } + +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('구매하기'), + ), + ), + ], + ), + ); + } +}