diff --git a/.vscode/launch.json b/.vscode/launch.json index 38989c1..33df1f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "type": "dart", "program": "lib/main.dart", "args": [ - "--dart-define=BASE_URL=http://192.168.1.104:8080", + "--dart-define=BASE_URL=http://192.168.72.210:8080", "--target-platform=android-arm64" ] }, diff --git a/lib/core/config.dart b/lib/core/config.dart index 7eb8809..11bd01d 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -8,7 +8,7 @@ class AppConfig { /// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید. static const String baseUrl = String.fromEnvironment( 'BASE_URL', - defaultValue: 'http://192.168.1.104:8080', + defaultValue: 'http://192.168.72.210:8080', ); static String get apiUrl => '$baseUrl/api'; diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index 00112ba..0a2b043 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -25,6 +25,7 @@ import '../../feature/profile/domain/use_cases/get_profile_usecase.dart'; import '../../feature/profile/domain/use_cases/save_profile_usecase.dart'; import '../../feature/profile/presentation/bloc/profile_bloc.dart'; import '../../feature/ranked/data/data_source/remote/ranked_api_provider.dart'; +import '../../feature/chat/data/data_source/remote/chat_api_provider.dart'; import '../../feature/ranked/data/repository/ranked_repository_impl.dart'; import '../../feature/ranked/domain/repository/ranked_repository.dart'; import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart'; @@ -62,6 +63,7 @@ Future setupLocator() async { locator.registerSingleton(ShopApiProvider()); locator.registerSingleton(ProfileApiProvider()); locator.registerSingleton(RankedApiProvider()); + locator.registerSingleton(ChatApiProvider()); locator.registerSingleton(GameApiProvider()); locator.registerSingleton(GameWsProvider(locator())); diff --git a/lib/feature/chat/data/data_source/remote/chat_api_provider.dart b/lib/feature/chat/data/data_source/remote/chat_api_provider.dart new file mode 100644 index 0000000..3bdc576 --- /dev/null +++ b/lib/feature/chat/data/data_source/remote/chat_api_provider.dart @@ -0,0 +1,33 @@ +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; +import '../../../domain/entities/chat_pack.dart'; + +/// دادهٔ بسته‌های چت از backend (دریافتِ بسته‌ها و خریدِ بسته با سکه). +class ChatApiProvider { + ApiProviderImp get _api => locator(); + + Future> getChatPacks() async { + final res = await _api.get('/chat-packs'); + if (res.statusCode != 200) { + throw Exception('chat-packs: ${res.statusCode}'); + } + final list = (res.data['packs'] as List?) ?? const []; + return list + .map((e) => ChatPack.fromJson(Map.from(e as Map))) + .toList(); + } + + /// خریدِ بسته. در صورتِ موفقیت null، وگرنه پیامِ خطای فارسی برمی‌گرداند. + Future buyChatPack(String packId) async { + final res = await _api.post('/shop/buy-chat-pack', body: {'pack_id': packId}); + if (res.statusCode == 200) return null; + switch (res.statusCode) { + case 402: + return 'سکهٔ کافی ندارید'; + case 409: + return 'این بسته را دارید'; + default: + return 'خرید ناموفق بود'; + } + } +} diff --git a/lib/feature/chat/domain/entities/chat_pack.dart b/lib/feature/chat/domain/entities/chat_pack.dart new file mode 100644 index 0000000..79fbf77 --- /dev/null +++ b/lib/feature/chat/domain/entities/chat_pack.dart @@ -0,0 +1,34 @@ +/// یک بسته‌ی پیام/شکلکِ چت (از backend). owned یعنی قابلِ استفاده است. +class ChatPack { + final String id; + final String title; + final String kind; // text | emoji + final int priceCoins; + final bool vip; // برای VIP رایگان است + final bool owned; + final List messages; + + const ChatPack({ + required this.id, + required this.title, + required this.kind, + required this.priceCoins, + required this.vip, + required this.owned, + required this.messages, + }); + + bool get isEmoji => kind == 'emoji'; + + factory ChatPack.fromJson(Map j) => ChatPack( + id: (j['id'] ?? '') as String, + title: (j['title'] ?? '') as String, + kind: (j['kind'] ?? 'text') as String, + priceCoins: (j['price_coins'] ?? 0) as int, + vip: (j['vip'] ?? false) as bool, + owned: (j['owned'] ?? false) as bool, + messages: ((j['messages'] as List?) ?? const []) + .map((e) => e as String) + .toList(), + ); +} diff --git a/lib/feature/chat/presentation/widgets/chat_panel.dart b/lib/feature/chat/presentation/widgets/chat_panel.dart new file mode 100644 index 0000000..91ec5f9 --- /dev/null +++ b/lib/feature/chat/presentation/widgets/chat_panel.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/locator/locator.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../data/data_source/remote/chat_api_provider.dart'; +import '../../domain/entities/chat_pack.dart'; + +/// پنلِ «بسته چت» شبیه تصویرِ مرجع: هدرِ ربانِ طلایی، تب برای هر بسته، و گریدِ +/// پیام‌ها/شکلک‌ها. بسته‌های قفل (نخریده) با دکمه‌ی خرید (سکه/VIP) نشان داده می‌شوند. +class ChatPanel extends StatefulWidget { + final void Function(String text) onText; + final void Function(String emoji) onEmoji; + final VoidCallback? onWalletChanged; // پس از خرید، موجودی را تازه کن + const ChatPanel({ + super.key, + required this.onText, + required this.onEmoji, + this.onWalletChanged, + }); + + static Future show( + BuildContext context, { + required void Function(String text) onText, + required void Function(String emoji) onEmoji, + VoidCallback? onWalletChanged, + }) { + return showDialog( + context: context, + barrierColor: Colors.black87, + builder: (_) => ChatPanel( + onText: onText, + onEmoji: onEmoji, + onWalletChanged: onWalletChanged, + ), + ); + } + + @override + State createState() => _ChatPanelState(); +} + +class _ChatPanelState extends State { + final _api = locator(); + late Future> _future; + String? _busyPack; // بسته‌ای که در حالِ خرید است + + @override + void initState() { + super.initState(); + _future = _api.getChatPacks(); + } + + void _reload() => setState(() => _future = _api.getChatPacks()); + + Future _buy(ChatPack p) async { + setState(() => _busyPack = p.id); + final err = await _api.buyChatPack(p.id); + if (!mounted) return; + setState(() => _busyPack = null); + if (err == null) { + widget.onWalletChanged?.call(); + _reload(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err), duration: const Duration(seconds: 2)), + ); + } + } + + void _pick(ChatPack p, String body) { + if (p.isEmoji) { + widget.onEmoji(body); + } else { + widget.onText(body); + } + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + child: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox( + height: 200, + child: Center( + child: CircularProgressIndicator(color: AppColors.gold)), + ); + } + final packs = snap.data ?? const []; + if (packs.isEmpty) { + return _shell(const Center( + child: Text('بسته‌ای موجود نیست', + style: TextStyle(color: Colors.white70)))); + } + return DefaultTabController( + length: packs.length, + child: _shell( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + isScrollable: true, + tabAlignment: TabAlignment.center, + indicator: BoxDecoration( + color: const Color(0xFF7B0E14), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.gold, width: 1.4), + ), + indicatorSize: TabBarIndicatorSize.tab, + labelColor: AppColors.gold, + unselectedLabelColor: Colors.white70, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + dividerColor: Colors.transparent, + tabs: [for (final p in packs) Tab(text: p.title)], + ), + const SizedBox(height: 10), + SizedBox( + height: 320, + child: TabBarView( + children: [for (final p in packs) _packView(p)], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + // قابِ قرمزِ پنل با ربانِ طلاییِ «بسته چت» و دکمه‌ی بستن. + Widget _shell(Widget child) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + margin: const EdgeInsets.only(top: 18), + padding: const EdgeInsets.fromLTRB(12, 30, 12, 14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF8E0E1B), Color(0xFF5A0A12)], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.gold, width: 2), + ), + child: child, + ), + // ربانِ عنوان + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 28, vertical: 6), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: const Text('بسته چت', + style: TextStyle( + color: Color(0xFF3A0A12), + fontSize: 17, + fontWeight: FontWeight.bold)), + ), + ), + ), + Positioned( + top: 14, + right: 6, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const CircleAvatar( + radius: 15, + backgroundColor: Color(0xFFB81D2A), + child: Icon(Icons.close, color: Colors.white, size: 18), + ), + ), + ), + ], + ); + } + + Widget _packView(ChatPack p) { + final grid = p.isEmoji ? _emojiGrid(p) : _textGrid(p); + if (p.owned) return grid; + // بسته‌ی قفل: گرید کم‌رنگ + دکمه‌ی خرید روی آن. + return Stack( + children: [ + IgnorePointer(child: Opacity(opacity: 0.35, child: grid)), + Center(child: _buyCard(p)), + ], + ); + } + + Widget _buyCard(ChatPack p) { + final busy = _busyPack == p.id; + return Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: const Color(0xCC2A0710), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.gold, width: 1.5), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (p.vip) + const Padding( + padding: EdgeInsets.only(bottom: 10), + child: Text('این بسته برای بازیکنان VIP رایگان است!', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.gold, fontSize: 13)), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + minimumSize: const Size(160, 46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: AppColors.gold, width: 1.4), + ), + ), + onPressed: busy ? null : () => _buy(p), + icon: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.monetization_on, color: AppColors.gold), + label: Text('${p.priceCoins}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + Widget _textGrid(ChatPack p) => GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(4), + childAspectRatio: 2.7, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: [ + for (final m in p.messages) + GestureDetector( + onTap: p.owned ? () => _pick(p, m) : null, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF9A1622), Color(0xFF5E0E16)], + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.goldDark, width: 1.2), + ), + child: Text(m, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold)), + ), + ), + ], + ); + + Widget _emojiGrid(ChatPack p) => GridView.count( + crossAxisCount: 5, + padding: const EdgeInsets.all(6), + children: [ + for (final e in p.messages) + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: p.owned ? () => _pick(p, e) : null, + child: + Center(child: Text(e, style: const TextStyle(fontSize: 32))), + ), + ], + ); +} diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 805f706..5d57918 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -16,7 +16,7 @@ import '../../../wallet/presentation/bloc/wallet_status.dart'; import '../../domain/entities/game_entities.dart'; import '../bloc/game_bloc.dart'; import '../bloc/game_state.dart'; -import '../widgets/chat_sheet.dart'; +import '../../../chat/presentation/widgets/chat_panel.dart'; import '../widgets/flame/hokm_game.dart'; import '../widgets/table_hud.dart'; @@ -154,10 +154,12 @@ class _GameScreenState extends State { void _openChat(BuildContext context) { final bloc = context.read(); - ChatSheet.show( + final wallet = context.read(); + ChatPanel.show( context, onText: bloc.sendChatText, onEmoji: bloc.sendChatEmoji, + onWalletChanged: () => wallet.add(LoadWalletEvent()), ); } diff --git a/lib/feature/game/presentation/widgets/chat_sheet.dart b/lib/feature/game/presentation/widgets/chat_sheet.dart deleted file mode 100644 index 94c24e7..0000000 --- a/lib/feature/game/presentation/widgets/chat_sheet.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../core/theme/app_theme.dart'; - -/// شیتِ «بسته چت»: شکلک‌ها و پیام‌های آماده. با انتخابِ هر مورد، بسته می‌شود و -/// همان لحظه برای همه‌ی میز پخش می‌شود (نمایش کنارِ آواتارِ فرستنده). -class ChatSheet extends StatelessWidget { - final void Function(String text) onText; - final void Function(String emoji) onEmoji; - const ChatSheet({super.key, required this.onText, required this.onEmoji}); - - static const _emojis = [ - '😀', '😂', '🤣', '😎', '😍', '😡', '😭', '👏', - '👍', '🔥', '💪', '🎉', '❤️', '🤔', '🙏', '😴', - ]; - - static const _phrases = [ - 'سلام', 'خسته نباشید', 'آفرین!', 'چه حرکتی!', - 'کارت تمومه', 'حواست کجاست؟', 'عجب شانسی!', 'دمت گرم', - 'بریم بعدی', 'ساکت!', 'خیلی عقبید', 'برادرمی', - 'کُت نشین!', 'حاکم فقط خودم', 'یاد گرفتی؟', 'رحم کن', - ]; - - static Future show( - BuildContext context, { - required void Function(String text) onText, - required void Function(String emoji) onEmoji, - }) { - return showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (_) => ChatSheet(onText: onText, onEmoji: onEmoji), - ); - } - - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF6A0D1A), AppColors.bgDark], - ), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - border: Border(top: BorderSide(color: AppColors.gold, width: 2)), - ), - padding: const EdgeInsets.only(bottom: 12), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - const Text('بسته چت', - style: TextStyle( - color: AppColors.gold, - fontSize: 18, - fontWeight: FontWeight.bold)), - const TabBar( - indicatorColor: AppColors.gold, - labelColor: AppColors.gold, - unselectedLabelColor: Colors.white54, - tabs: [Tab(text: 'شکلک'), Tab(text: 'پیام‌ها')], - ), - SizedBox( - height: 240, - child: TabBarView( - children: [ - _emojiGrid(context), - _phraseList(context), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _emojiGrid(BuildContext context) => GridView.count( - crossAxisCount: 5, - padding: const EdgeInsets.all(12), - children: [ - for (final e in _emojis) - InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - onEmoji(e); - Navigator.pop(context); - }, - child: Center(child: Text(e, style: const TextStyle(fontSize: 34))), - ), - ], - ); - - Widget _phraseList(BuildContext context) => GridView.count( - crossAxisCount: 2, - padding: const EdgeInsets.all(12), - childAspectRatio: 3.2, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - children: [ - for (final p in _phrases) - GestureDetector( - onTap: () { - onText(p); - Navigator.pop(context); - }, - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF8E1620), Color(0xFF5A0E14)], - ), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.goldDark, width: 1.2), - ), - child: Text( - p, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ), - ), - ], - ); -}