hapticlink / client /lib /home_screen.dart
Anuj-Panthri's picture
pingInterval now working on android
0409bf8
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<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
@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<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)!,
),
]),
),
),
);
}
@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"),
),
),
]),
),
),
]),
);
}
}