Spaces:
Paused
Paused
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/services.dart'; // for going in fullscreen mode | |
| import 'package:flutter/scheduler.dart'; // to add scheduler start sending touch on touch start | |
| import 'package:shared_preferences/shared_preferences.dart'; | |
| // import 'package:web_socket_channel/status.dart' as status; | |
| // import 'ws_server_connection_handler.dart'; | |
| import 'package:web_socket_client/web_socket_client.dart'; | |
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | |
| import 'package:flutter/foundation.dart' | |
| show kIsWeb; // to check of platform is web | |
| import 'vibration/vibration_exporter.dart'; | |
| import "dart:convert"; | |
| import "dart:async"; | |
| void enterFullScreen() { | |
| SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive, overlays: []); | |
| } | |
| void exitFullScreen() { | |
| SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, | |
| overlays: SystemUiOverlay.values); | |
| } | |
| class HomeScreen extends StatefulWidget { | |
| const HomeScreen({super.key}); | |
| State<HomeScreen> createState() { | |
| return HomeScreenState(); | |
| } | |
| } | |
| class HomeScreenState extends State<HomeScreen> { | |
| late WebSocket socket; | |
| late double width, height; | |
| late SharedPreferences prefs; | |
| double containerHeight = 0, containerWidth = 0; | |
| late Timer sendTouchScheduler; | |
| Timer endSendTouchTimer = Timer(Duration.zero, () {}); | |
| double touchsize = 100; | |
| double touchX = 0, touchY = 0; | |
| bool isTouching = false; | |
| bool hasCollided = | |
| false; // hascollided can be useful to show any type special effect later on | |
| late List<dynamic> userList; | |
| late List<String> roomidList, roomnameList; | |
| String currRoomId = "", currRoomName = ""; | |
| String username = ""; | |
| String userid = ""; | |
| String profileColor = "ff000000"; | |
| Map<String, Map> othersTouchPoints = {}; | |
| final double outsidepadding = 15; | |
| final int touchRefreshRate = 1000 ~/ 30; // 30 fps | |
| // final int touchRefreshRate = 1000~/60; // 60 fps | |
| // bool isConnected = false; | |
| GlobalKey headerKey = GlobalKey(); // to calculate the header's height | |
| void initState() { | |
| super.initState(); | |
| enterFullScreen(); | |
| roomidList = []; | |
| roomnameList = []; | |
| userList = []; | |
| // roomidList=["1","2"]; | |
| // roomnameList=["room 1","room 2"]; | |
| Map data = { | |
| "user": { | |
| "username": "enemy", | |
| "id": "string", | |
| }, | |
| "position": { | |
| "x": 0.0 + touchsize / 2, // top left corner | |
| "y": 0.0 + touchsize / 2, // top left corner | |
| }, | |
| "color": "ff2244bb", // Hex value. | |
| "intensity": 1, // Vibration intensity. | |
| }; | |
| othersTouchPoints[data['user']['id']] = data; | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| // this function is called after the build method is done executing | |
| getPreferences(); | |
| connectWebsocket(); | |
| }); | |
| // just for debugging | |
| // WidgetsBinding.instance.addPostFrameCallback((_){ | |
| // showSnackBar(getHeaderHeight().toString()); | |
| // }); | |
| // just for debugging vibrations | |
| // Vibration.vibrate(pattern:[100]); | |
| // Vibration.vibrate(pattern:[200,100]); | |
| } | |
| void dispose() { | |
| exitFullScreen(); | |
| socket.close(); | |
| super.dispose(); | |
| } | |
| void showSnackBar(String text) { | |
| ScaffoldMessenger.of(context).hideCurrentSnackBar(); | |
| ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); | |
| } | |
| double? getHeaderHeight() { | |
| var size = headerKey.currentContext?.size; | |
| // debugPrint(size?.height); | |
| return size?.height; | |
| } | |
| void getPreferences() async { | |
| prefs = await SharedPreferences.getInstance(); | |
| // initialize empty room list if no preferences found | |
| if (!prefs.containsKey("roomid_list")) { | |
| prefs.setStringList("roomid_list", []); | |
| prefs.setStringList("roomname_list", []); | |
| } | |
| // intialize from preferences | |
| setState(() { | |
| roomidList = prefs.getStringList("roomid_list")!; | |
| roomnameList = prefs.getStringList("roomname_list")!; | |
| username = prefs.getString( | |
| "username")!; // to get the username from device's local storage | |
| profileColor = prefs.getString( | |
| "profile_color")!; // to get the profilecolor from device's local storage | |
| }); | |
| } | |
| void connectWebsocket() async { | |
| const timeout = Duration(seconds: 10); | |
| const backoff = ConstantBackoff(Duration(seconds: 1)); | |
| const pingInterval = Duration(seconds: 3); | |
| final socketUri = Uri.parse(const String.fromEnvironment("WS_SERVER_URL", | |
| defaultValue: "ws://localhost:3000")); | |
| socket = WebSocket( | |
| socketUri, | |
| timeout: timeout, | |
| backoff: backoff, | |
| pingInterval: pingInterval, | |
| ); | |
| socket.connection.listen((state) { | |
| debugPrint("state:\t $state"); | |
| if (state is Connected || state is Reconnected) { | |
| // isConnected = true; | |
| onConnected(); | |
| } | |
| // isConnected = false; | |
| if (state is Connecting || state is Reconnecting) { | |
| showSnackBar("Connecting"); | |
| } | |
| if (state is Disconnected) { | |
| showSnackBar("Disconnected"); | |
| } | |
| }); | |
| socket.messages.listen(responseHandler); | |
| } | |
| bool isConnected(){ | |
| return socket.connection.state is Connected || socket.connection.state is Reconnected; | |
| } | |
| void onConnected() { | |
| showSnackBar("connected"); | |
| setUsername(); | |
| // on connected test connection | |
| Object data = {"route": "test_connection"}; | |
| socket.send(json.encode(data)); | |
| } | |
| void setUsername() async { | |
| prefs = await SharedPreferences.getInstance(); | |
| if (!isConnected()) { | |
| return; | |
| } | |
| // set username | |
| Map data = { | |
| "route": "set_username", | |
| "username": prefs.getString("username"), | |
| }; | |
| socket.send(json.encode(data)); | |
| } | |
| void responseHandler(message) { | |
| // message.get | |
| Map map; | |
| final String type; | |
| try { | |
| map = jsonDecode(message); // try to decode the message into json format | |
| } catch (e) { | |
| debugPrint("can't decode to json:\t$message"); | |
| return; | |
| } | |
| // if any error found do nothing | |
| if (map.containsKey("error")) { | |
| debugPrint(map.toString()); | |
| return; | |
| } | |
| type = map["message"]; // get the type of the message | |
| if (type == "test_connection_response") { | |
| // testing connection | |
| debugPrint("test_connection successful"); | |
| } else if (type == "pong") { | |
| debugPrint("pong"); // handling pongs | |
| } else if (type == "set_username_response") { | |
| debugPrint(map.toString()); | |
| setState(() { | |
| userid = map['user']['id']; // set our userid | |
| }); | |
| } else if (type == "join_room_response") { | |
| // on error | |
| // debugPrint(map.toString()); | |
| showSnackBar(map['status']); | |
| } else if (type == "room_update") { | |
| debugPrint(map.toString()); | |
| if (currRoomId != map['roomId']) { | |
| // only update on room change | |
| setState(() { | |
| currRoomId = map['roomId']; | |
| currRoomName = map['roomId']; | |
| }); | |
| } | |
| // update users list | |
| setState(() { | |
| userList = map['users']; | |
| }); | |
| } else if (type == "send_vibration_response") { | |
| // handles when we send_touch without being in a room | |
| debugPrint(map.toString()); | |
| showSnackBar( | |
| map['status']); // shows error that we are not part of the room | |
| setState(() { | |
| currRoomId = ""; // clears the current roomid | |
| currRoomName = ""; | |
| userList = []; | |
| }); | |
| } else if (type == "receive_touch") { | |
| if (map['user']['id'] == userid) { | |
| // this is touch which we originated so we ignore it | |
| // debugPrint("self touch received. Ignore this"); | |
| return; | |
| } | |
| // touch received to update othersTouchPoints | |
| // debugPrint(map); | |
| if (map['type'] == 'enabled') { | |
| // add touch point | |
| // static height and width | |
| double staticHeight = getHeaderHeight()! + | |
| outsidepadding * 2; // this is the extra static height | |
| double staticWidth = | |
| outsidepadding * 2; // this is the extra static width | |
| // rescale point according to the screen | |
| map['position']['x'] *= width - staticWidth; | |
| map['position']['y'] *= height - staticHeight; | |
| // debugPrint(map['position']['x'].toString()); | |
| // debugPrint(map['position']['y'].toString()); | |
| setState(() { | |
| othersTouchPoints[map['id']] = map; // updating other's touch points | |
| }); | |
| } else { | |
| // map['type']=='disabled' | |
| setState(() { | |
| othersTouchPoints.remove(map['id']); // delete the touch point | |
| }); | |
| } | |
| } | |
| } | |
| void toggleContainer() { | |
| // to toggle the visibility of the center popup container | |
| if (containerHeight == 0) { | |
| setState(() { | |
| containerHeight = 200; | |
| containerWidth = 220; | |
| }); | |
| } else { | |
| setState(() { | |
| containerHeight = 0; | |
| containerWidth = 0; | |
| }); | |
| } | |
| } | |
| void createRoom({String roomId = ""}) async { | |
| // it is used to create a room in server | |
| // or to connect to an existing room | |
| Map data; | |
| print(isConnected()); | |
| if (!isConnected()) { | |
| return; | |
| } | |
| // join room | |
| data = { | |
| "route": "join_room", | |
| "username": username, | |
| }; | |
| // to join existing room | |
| if (roomId.isNotEmpty) { | |
| data["roomId"] = roomId; | |
| } | |
| socket.send(json.encode(data)); | |
| // we will get in the room with the server data | |
| // from the room_update response | |
| } | |
| void joinRoomForm() { | |
| // popup for entering the room id to join an existing room | |
| showDialog( | |
| context: context, | |
| builder: (context) { | |
| return AlertDialog( | |
| content: SingleChildScrollView( | |
| child: Column(children: [ | |
| TextField( | |
| decoration: const InputDecoration( | |
| hintText: "Enter Room ID", | |
| ), | |
| onSubmitted: (value) { | |
| Navigator.pop(context); // close the dialog | |
| createRoom(roomId: value); // join room | |
| }, | |
| ), | |
| ])), | |
| ); | |
| }, | |
| ); | |
| } | |
| Widget header() { | |
| Color buttoncolor = const Color.fromARGB(255, 70, 70, 70); | |
| return Padding( | |
| key: headerKey, | |
| padding: const EdgeInsets.symmetric(vertical: 20), | |
| child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ | |
| // sub row to group create/join room button and roomuserslist | |
| Row(children: [ | |
| // create/join room popup | |
| CircleAvatar( | |
| backgroundColor: buttoncolor, | |
| child: PopupMenuButton( | |
| onSelected: (value) { | |
| switch (value) { | |
| case 'createRoom': | |
| return createRoom(); | |
| case 'joinRoom': | |
| return joinRoomForm(); | |
| default: | |
| throw UnimplementedError(); | |
| } | |
| }, | |
| icon: const Icon(Icons.add, color: Colors.white), // + icon | |
| tooltip: "Create/Join Room", | |
| itemBuilder: (context) => const [ | |
| PopupMenuItem( | |
| value: "createRoom", | |
| child: Text("Create Room"), | |
| ), | |
| PopupMenuItem( | |
| value: "joinRoom", | |
| child: Text("Join Room"), | |
| ), | |
| ], | |
| ), | |
| ), | |
| // users list | |
| Container( | |
| margin: const EdgeInsets.only(left: 10), | |
| child: Row(children: [ | |
| ...userList.map( | |
| (user) { | |
| return Container( | |
| // each person's name who is in a room | |
| height: 50, | |
| width: 50, | |
| margin: const EdgeInsets.only(left: 4), | |
| alignment: Alignment.center, | |
| decoration: BoxDecoration( | |
| color: Colors | |
| .white, // white background // we can also use their profilecolor here | |
| borderRadius: BorderRadius.circular(50), | |
| ), | |
| child: Text( | |
| user["username"], // their username | |
| style: const TextStyle( | |
| color: Colors.black, | |
| ), | |
| ), | |
| ); | |
| }, | |
| ).toList(), | |
| ]), | |
| ) | |
| ]), | |
| // centered Lobby thing | |
| GestureDetector( | |
| onTap: toggleContainer, | |
| onLongPress: () async { | |
| // long press to copy roomid to clipboard | |
| // copy to clipboard | |
| await Clipboard.setData(ClipboardData(text: currRoomId)); | |
| showSnackBar("copied!"); | |
| }, | |
| child: Row(// roomid+dropdown_icon | |
| children: [ | |
| // roomid | |
| Text( | |
| currRoomName.isEmpty ? "Lobby" : currRoomName, | |
| style: const TextStyle( | |
| fontSize: 20, | |
| fontWeight: FontWeight.w500, | |
| overflow: TextOverflow.ellipsis, | |
| ), | |
| ), | |
| // icon based on if the container is visible or not | |
| if (containerHeight == 0) ...[ | |
| const Icon(Icons.arrow_drop_down), | |
| ] else ...[ | |
| const Icon(Icons.arrow_drop_up), | |
| ] | |
| ]), | |
| ), | |
| // settings button | |
| CircleAvatar( | |
| backgroundColor: buttoncolor, | |
| child: IconButton( | |
| tooltip: | |
| "setings", // text which is showed when long pressing or hovering | |
| onPressed: () {}, // does nothing till now | |
| icon: const Icon( | |
| Icons.settings, | |
| color: Colors.white, | |
| ), | |
| ), | |
| ), | |
| ]), | |
| ); | |
| } | |
| void startSendTouch() { | |
| // to start the send_touch scheduler | |
| // endSendTouchTimer.cancel(); | |
| sendTouchScheduler = | |
| Timer.periodic(Duration(milliseconds: touchRefreshRate), (timer) async { | |
| // send touch to server | |
| Map data; | |
| double staticHeight = getHeaderHeight()! + | |
| outsidepadding * | |
| 2; // this is the extra static height which change with resize | |
| double staticWidth = outsidepadding * | |
| 2; // this is the extra static height which change with resize | |
| // debugPrint("isaAlive:"+channel.isAliave.toString()); | |
| debugPrint("isConnected():" + isConnected().toString()); | |
| // debugPrint("see:"+channel.sink.toString()); | |
| if (!isConnected()) { | |
| return; | |
| } | |
| data = { | |
| "route": "send_touch", | |
| "id": 1, | |
| "type": "enabled", // Whether the vibration is active or not. | |
| "position": { | |
| "x": touchX / | |
| (width - | |
| staticWidth), // send ratio x e.g. : x:700, width:800, then send 0.875 | |
| "y": touchY / | |
| (height - | |
| staticHeight), // send ratio y e.g. : y:400, height:800 then send 0.5 | |
| }, | |
| "color": profileColor, // Hex value. Default: random | |
| // "intensity"?: 1 // Vibration intensity. Default: 1 | |
| }; | |
| // debugPrint(data['position']['x']); | |
| // debugPrint(data['position']['y']); | |
| socket.send(jsonEncode(data)); | |
| }); | |
| // endSendTouchTimer = Timer(const Duration(milliseconds: 1000),() {sendTouchScheduler.cancel(); }); | |
| } | |
| void stopSendTouch() async { | |
| // to stop the send_touch scheduler | |
| sendTouchScheduler.cancel(); | |
| // send one last touch to tell touch is ended | |
| Map data; | |
| if (!isConnected()) { | |
| return; | |
| } | |
| data = { | |
| "route": "send_touch", | |
| "id": 1, | |
| "type": "disabled", // Whether the vibration is active or not. | |
| "position": { | |
| "x": 0, | |
| "y": 0, | |
| }, | |
| "color": profileColor, // Hex value. Default: random | |
| // "intensity"?: 1 // Vibration intensity. Default: 1 | |
| }; | |
| socket.send(jsonEncode(data)); | |
| } | |
| Widget buildTouchPoint( | |
| {required bool visible, | |
| required double x, | |
| required double y, | |
| required double size, | |
| required Color color}) { | |
| /* | |
| bool visible: if the touchpoint is visible | |
| double x: touchpoint's center x position | |
| double y: touchpoint's center y position | |
| double y: touchpoint's center y position | |
| double size: touchpoint's size | |
| Color color: touchpoint's color | |
| */ | |
| color = color.withOpacity(0.5); // make the color a little translucent | |
| return Visibility( | |
| visible: visible, | |
| child: AnimatedPositioned( | |
| duration: Duration(milliseconds: touchRefreshRate), | |
| left: x - size / 2, | |
| top: y - size / 2, | |
| child: Container( | |
| width: size, | |
| height: size, | |
| decoration: BoxDecoration( | |
| color: color, | |
| borderRadius: BorderRadius.circular(50), | |
| boxShadow: [ | |
| BoxShadow( | |
| color: color, | |
| blurRadius: 10, | |
| spreadRadius: 5, | |
| ), | |
| ]), | |
| ), | |
| ), | |
| ); | |
| } | |
| void detectTouchCollision({ | |
| required double x, | |
| required double y, | |
| }) { | |
| // detect our touch point collision with any other's TouchPoints | |
| for (Map map in othersTouchPoints.values) { | |
| double dx = (map["position"]["x"] - x).abs(); | |
| double dy = (map["position"]["y"] - y).abs(); | |
| // debugPrint("x:"+x.toString()+"\ty:"+y.toString()); | |
| // debugPrint("dx:"+dx.toString()+"\tdy:"+dy.toString()); | |
| if (dx <= touchsize && dy <= touchsize) { | |
| if (hasCollided == false) { | |
| // Collision Started | |
| debugPrint("Touch Collision started"); | |
| startHapticFeedback(); | |
| setState(() { | |
| hasCollided = true; | |
| }); | |
| } | |
| } else { | |
| if (hasCollided == true) { | |
| // collision ended | |
| debugPrint("Touch Collision ended"); | |
| endHapticFeedback(); | |
| setState(() { | |
| hasCollided = false; | |
| }); | |
| } | |
| // debugPrint("No Touch Collision."); | |
| } | |
| } | |
| } | |
| void startHapticFeedback() async { | |
| if (kIsWeb) { | |
| // different handler for web | |
| // showSnackBar("web"); | |
| // off,on,off | |
| Vibration.vibrate(pattern: [20, 100], repeat: 0); // short fast | |
| // Vibration.vibrate(pattern:[100, 200, 400],repeat: 0); // slow | |
| } else { | |
| // showSnackBar("mobile"); | |
| if (await Vibration.hasVibrator() == true) { | |
| // off,on,off | |
| Vibration.vibrate(pattern: [20, 100], repeat: 0); // short fast | |
| // Vibration.vibrate(pattern:[100, 200, 400],repeat: 0); // slow | |
| } | |
| } | |
| } | |
| void endHapticFeedback() async { | |
| if (kIsWeb) { | |
| // different handler for web | |
| Vibration.cancel(); | |
| } else { | |
| if (await Vibration.hasVibrator() == true) { | |
| Vibration.cancel(); | |
| } | |
| } | |
| } | |
| void detectTouchStart(DragStartDetails details) { | |
| // to detect touch start on screen to start the send_touch scheduler | |
| // debugPrint(details.localPosition); | |
| // update our touch point on our screen | |
| setState(() { | |
| touchX = details.localPosition.dx; | |
| touchY = details.localPosition.dy; | |
| isTouching = true; // show it | |
| }); | |
| // detect Collision for haptic feedback | |
| detectTouchCollision( | |
| x: details.localPosition.dx, | |
| y: details.localPosition.dy, | |
| ); | |
| // start send Touch Scheduler | |
| if (currRoomId.isNotEmpty) { | |
| // only send touch if we are part of a room | |
| SchedulerBinding.instance.addPostFrameCallback((_) { | |
| startSendTouch(); | |
| }); | |
| } else { | |
| // we are not joined in a room | |
| // so can't send_touch | |
| } | |
| } | |
| void detectTouchUpdate(DragUpdateDetails details) { | |
| // to update touchpoint | |
| // debugPrint(details.localPosition); | |
| // update our touch point on our screen | |
| setState(() { | |
| touchX = details.localPosition.dx; | |
| touchY = details.localPosition.dy; | |
| }); | |
| // detect Collision | |
| detectTouchCollision( | |
| x: details.localPosition.dx, | |
| y: details.localPosition.dy, | |
| ); | |
| } | |
| void detectTouchEnd(DragEndDetails details) { | |
| // triggered on touch end | |
| // update our touch point on our screen | |
| setState(() { | |
| touchX = 0; | |
| touchY = 0; | |
| isTouching = false; // hide it | |
| }); | |
| // detect Collision end | |
| detectTouchCollision( | |
| x: -1000, | |
| y: -1000, | |
| ); | |
| // stop send Touch Scheduler and send stop touch message | |
| if (currRoomId.isNotEmpty) { | |
| SchedulerBinding.instance.addPostFrameCallback((_) { | |
| stopSendTouch(); | |
| }); | |
| } else { | |
| // can't send_touch end to server cuz we are not in a room | |
| } | |
| } | |
| Widget mainSection() { | |
| // this is the main touch area | |
| return Expanded( | |
| // expanded for it to take up all the available space | |
| child: GestureDetector( | |
| onPanStart: (details) { | |
| detectTouchStart(details); // on touch start | |
| }, | |
| onPanUpdate: (details) { | |
| detectTouchUpdate(details); // on touch update | |
| }, | |
| onPanEnd: (details) { | |
| detectTouchEnd(details); // on touch end | |
| }, | |
| child: Container( | |
| decoration: BoxDecoration( | |
| color: Colors.grey.shade900, | |
| borderRadius: BorderRadius.circular(50), | |
| ), | |
| clipBehavior: Clip.hardEdge, | |
| child: Stack(clipBehavior: Clip.hardEdge, children: [ | |
| // render received touch points | |
| ...othersTouchPoints.values.map<Widget>((map) { | |
| return buildTouchPoint( | |
| visible: true, | |
| x: map['position']['x'], | |
| y: map['position']['y'], | |
| size: touchsize, | |
| color: colorFromHex(map['color'])!, | |
| ); | |
| }).toList(), | |
| // our touch point | |
| buildTouchPoint( | |
| visible: isTouching, | |
| x: touchX, | |
| y: touchY, | |
| size: touchsize, | |
| color: colorFromHex(profileColor)!, | |
| ), | |
| ]), | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget build(BuildContext context) { | |
| width = MediaQuery.of(context).size.width; // to get screen width | |
| height = MediaQuery.of(context).size.height; // to get screen height | |
| return Scaffold( | |
| body: Stack(alignment: Alignment.center, children: [ | |
| //outermost background | |
| Container( | |
| color: const Color.fromARGB(255, 14, 14, 14), | |
| ), | |
| // header and main touch area | |
| Padding( | |
| padding: EdgeInsets.all(outsidepadding), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment | |
| .stretch, // mainSection expands in height because of this | |
| children: [ | |
| header(), | |
| mainSection(), | |
| ], | |
| ), | |
| ), | |
| //floating Room menu | |
| Positioned( | |
| top: 100, | |
| child: AnimatedContainer( | |
| duration: const Duration(milliseconds: 100), | |
| height: containerHeight, | |
| width: containerWidth, | |
| clipBehavior: Clip.hardEdge, | |
| curve: Curves.easeIn, | |
| decoration: BoxDecoration( | |
| color: const Color.fromARGB(255, 67, 67, 67), | |
| borderRadius: BorderRadius.circular(20), | |
| ), | |
| child: ListView(children: [ | |
| Container( | |
| margin: const EdgeInsets.all(20) + | |
| const EdgeInsets.symmetric(horizontal: 40), | |
| child: TextButton( | |
| // exit room button | |
| onPressed: () {}, | |
| style: TextButton.styleFrom( | |
| backgroundColor: const Color.fromARGB(255, 239, 83, 80), | |
| foregroundColor: const Color.fromARGB(255, 255, 255, 255), | |
| shape: RoundedRectangleBorder( | |
| borderRadius: BorderRadius.circular(20)), | |
| ), | |
| child: const Text("Exit Room"), | |
| ), | |
| ), | |
| ]), | |
| ), | |
| ), | |
| ]), | |
| ); | |
| } | |
| } | |