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}); @override State createState() { return HomeScreenState(); } } class HomeScreenState extends State { 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 userList; late List roomidList, roomnameList; String currRoomId = "", currRoomName = ""; String username = ""; String userid = ""; String profileColor = "ff000000"; 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 @override 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]); } @override 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((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)!, ), ]), ), ), ); } @override 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"), ), ), ]), ), ), ]), ); } }