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('๊ตฌ๋งคํ•˜๊ธฐ'), + ), + ), + ], + ), + ); + } +}