import 'package:flutter/material.dart'; import 'package:flutter_boring_avatars/flutter_boring_avatars.dart'; import 'package:get/get.dart'; import 'package:huacu_mobile/game_pallete.dart'; import 'dart:math'; import 'package:huacu_mobile/models/auth_data.dart'; import 'package:huacu_mobile/models/available_game.dart'; import 'package:huacu_mobile/models/game.dart'; import 'package:huacu_mobile/models/settings_item_model.dart'; import 'package:huacu_mobile/models/user_data.dart'; import 'package:huacu_mobile/ui/widgets/settings.dart'; import 'package:huacu_mobile/ui/widgets/socket_connection_indicator.dart'; import 'package:huacu_mobile/ui/widgets/user_card.dart'; import 'package:socket_io_client/socket_io_client.dart' as io; import 'package:styled_widget/styled_widget.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); } class _HomePageState extends State { final io.Socket socket = Get.find(); final AuthData authData = Get.find(); UserData userData = const UserData(0, 0); final availableGames = [].obs; @override void initState() { super.initState(); socket.on("hello", _onHelloEvent); socket.on("update", _onUpdateEvent); socket.on("updateNeeded", _onUpdateNeededEvent); socket.on("removeGameResponse", _onRemoveGameResponseEvent); socket.on("createGameResponse", _onCreateGameResponseEvent); socket.on("getUserDataResponse", _onGetUserDataResponseEvent); socket.on("joinGameResponse", onJoinGameResponseEvent); socket.emit("getUserData"); socket.emit("getUpdate"); } void _onHelloEvent(dynamic idky) { socket.dispose(); Get.offAllNamed("/auth"); } void _onUpdateEvent(dynamic update) { bool ok = update[0]; if (ok) { var data = update[1]; availableGames.value = (data["availableGames"] as List? ?? []) .map((e) => AvailableGame( id: e["id"], opponentName: e["player"], tries: e["tries"], neededRole: e["neededRole"], )) .toList(growable: false); } else { Get.snackbar("Error", "Update failed with message: ${update[1]}"); } } void _onUpdateNeededEvent(dynamic data) { socket.emit("getUpdate"); } void _onRemoveGameResponseEvent(dynamic data) {} void _onCreateGameResponseEvent(dynamic data) {} void _onGetUserDataResponseEvent(dynamic data) { bool ok = data[0]; if (ok) { userData = UserData( data[1]["client"]["wins"], data[1]["client"]["losses"], ); } else { Get.snackbar("Error", "Failed to get user data:\n ${data[1]}"); } } void onJoinGameResponseEvent(dynamic data) { bool ok = data[0]; if (ok) { Get.put(authData); Get.put(socket); Get.put(Game( data[1]["id"], data[1]["guesser"], data[1]["suggester"], data[1]["tries"], (data[2] as List).map((e) => e.toString()).toSet(), )); Get.offNamed("/game"); } else { Get.snackbar("Request response", data[1]); } } @override void dispose() { socket.off("hello", _onHelloEvent); socket.off("update", _onUpdateEvent); socket.off("updateNeeded", _onUpdateNeededEvent); socket.off("removeGameResponse", _onRemoveGameResponseEvent); socket.off("createGameResponse", _onCreateGameResponseEvent); socket.off("getUserDataResponse", _onGetUserDataResponseEvent); socket.off("joinGameResponse", onJoinGameResponseEvent); super.dispose(); } @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; return Scaffold( appBar: AppBar( title: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const Text("HUACU"), SocketConnectionIndicator(socket: socket, size: 8), ], ), Obx( () => Text( "Available ${availableGames.length} game(s)", style: const TextStyle(fontSize: 12), ), ), ], ), actions: [ InkResponse( onTap: _openProfile, child: BoringAvatars( name: authData.id, colors: avatarColors, type: BoringAvatarsType.beam, ).paddingAll(8), ), ], ), body: SizedBox( width: size.width, height: size.height, child: Column( children: [ Expanded( child: ObxValue( (data) => ListView.builder( itemCount: data.length, itemBuilder: (context, index) { final game = data[index]; final you = authData.login == game.opponentName; return Card( child: ListTile( title: Text( "Game of ${game.opponentName} ${you ? "(you)" : ""}" .trim(), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("Tries: ${game.tries}"), Text("Needed role: ${game.neededRole}"), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (!you) InkResponse( onTap: () => _joinGame(game.id), child: const Icon(Icons.connect_without_contact), ), if (you) InkResponse( onTap: () => _deleteGame(game.id), child: const Icon(Icons.delete), ), ], ), ), ); }, ), availableGames, ), ), Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: ObxValue( (data) { return ElevatedButton( onPressed: data.firstWhereOrNull((g) => g.opponentName == authData.login) == null ? _createGame : null, child: const Text("Create"), ); }, availableGames, ), ), ], ), ), ], ), ), ); } void _createGame() async { var tries = 20; var role = "guesser"; var controller = TextEditingController(text: tries.toString()); final data = await Get.dialog?>( StatefulBuilder(builder: (buildContext, setState) { return AlertDialog( title: const Text("New game configuration"), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("Tries:"), Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 32, child: IconButton.outlined( iconSize: 8, onPressed: tries <= 10 ? null : () { setState(() { tries = max(tries - 1, 10); controller.text = tries.toString(); }); }, icon: const Icon(Icons.remove), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: controller, keyboardType: TextInputType.number, onChanged: (value) { setState(() { tries = clamp(int.tryParse(value) ?? 20, 10, 50); controller.text = tries.toString(); }); }, ), ), const SizedBox(width: 8), SizedBox( width: 32, child: IconButton.outlined( iconSize: 8, onPressed: tries >= 50 ? null : () { setState(() { tries = min(tries + 1, 50); controller.text = tries.toString(); }); }, icon: const Icon(Icons.add), ), ), ], ).paddingOnly(bottom: 16), const Text("Needed role:"), Row( children: [ Expanded( child: DropdownButton( value: role, items: const [ DropdownMenuItem( value: "guesser", child: Text("Guesser"), ), DropdownMenuItem( value: "suggester", child: Text("Suggester"), ), ], onChanged: (value) { setState(() { role = value ?? "guesser"; }); }, ), ), ], ), ], ), actions: [ TextButton( onPressed: () { Get.back(); }, child: const Text("Cancel"), ), TextButton( onPressed: () { Get.back(result: { "tries": tries, "role": role, }); }, child: const Text("Create"), ), ], ); })); if (data == null) return; final triesSelected = data["tries"] as int; final roleSelected = data["role"] as String; socket.emit("createGame", [triesSelected, roleSelected]); Get.snackbar("Game created", "Game created"); } int clamp(int value, int min, int max) { if (value <= min) return min; if (value >= max) return max; return value; } void _joinGame(String gameId) { socket.emit("joinGame", gameId); } void _deleteGame(String gameId) { Get.defaultDialog( title: "Delete game", content: const Text("Are you sure you want to delete this game?"), textConfirm: "Delete", onConfirm: () { Get.back(); socket.emit("removeGame", gameId); }, textCancel: "Cancel", onCancel: () { Get.back(); }, ); } void _onLogoutTap() { socket.emit("logout"); } void _onAccountDeletedHandler(dynamic data) { if (data[0]) { Get.back(); Get.offAllNamed("/auth"); } else { Get.snackbar("Error", "Failed to delete account:\n${data[1]}"); } socket.off("deleteAccountResponse", _onAccountDeletedHandler); } void _onDeleteAccountTap() async { final textEditingController = TextEditingController(); bool ok = await Get.defaultDialog( title: "Delete game", content: [ const Text( "Are you sure you want to delete your account? This action is irreversible.", ), const Text("Enter your password to confirm:"), TextField( controller: textEditingController, obscureText: true, ), ].toColumn( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, ), textConfirm: "Delete", onConfirm: () { Get.back(result: true); }, textCancel: "Cancel", onCancel: () { Get.back(result: false); }, ); final password = textEditingController.text; if (!ok || password.isEmpty) return; socket.on("deleteAccountResponse", _onAccountDeletedHandler); socket.emit("deleteAccount", password); } void _openProfile() { Get.bottomSheet(BottomSheet( onClosing: () {}, clipBehavior: Clip.antiAliasWithSaveLayer, builder: (context) => _buildBottomSheetProfileContent(), )); } Widget _buildBottomSheetProfileContent() { page({required Widget child}) => Styled.widget(child: child) .padding(vertical: 30, horizontal: 20) .scrollable(); return [ const Text( 'Your account', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32), ).alignment(Alignment.center).padding(bottom: 20), UserCard(authData: authData, userData: userData), Settings(settingsItems: [ SettingsItemModel( icon: Icons.logout, color: Colors.purpleAccent.shade200, title: "Logout", description: "We will miss you", onTap: _onLogoutTap, ), SettingsItemModel( icon: Icons.dangerous, color: Colors.redAccent, title: "Delete my account", description: "Beware - this action is irreversible", onTap: _onDeleteAccountTap, ), ]), ].toColumn(mainAxisSize: MainAxisSize.min).parent(page); } }