完成全部功能

This commit is contained in:
梦凌汐 2025-07-03 09:17:39 +08:00
parent 1736ce9159
commit de09cf25b8
10 changed files with 614 additions and 188 deletions

View File

@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@ -5,6 +8,12 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.example.marscar_controller" namespace = "com.example.marscar_controller"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -21,7 +30,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.marscar_controller" applicationId = "cn.meowdream.marscar_controller"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@ -30,6 +39,14 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.

View File

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:label="marscar_controller" android:label="宇宙级飞船奶牛抓捕队"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@ -47,4 +47,5 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE"/>
</manifest> </manifest>

File diff suppressed because one or more lines are too long

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -34,8 +34,8 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
carIpController.text = '192.168.137.122'; carIpController.text = '192.168.137.34';
cameraIpController.text = '192.168.137.138'; cameraIpController.text = '192.168.137.121';
GlobalSettingState globalSettingState = context.watch<GlobalSettingState>(); GlobalSettingState globalSettingState = context.watch<GlobalSettingState>();
return Scaffold( return Scaffold(
body: globalSettingState.cameraIP != '' && globalSettingState.carIP != '' ? body: globalSettingState.cameraIP != '' && globalSettingState.carIP != '' ?

View File

@ -10,6 +10,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:marscar_controller/pages/square_joystick.dart';
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
const PlayerPage({super.key}); const PlayerPage({super.key});
@ -26,7 +27,7 @@ class _PlayerPageState extends State<PlayerPage> {
Timer? _reconnectionTimer; Timer? _reconnectionTimer;
final int callBackPeriod = 20; final int callBackPeriod = 10;
RawDatagramSocket? _udpSocket; RawDatagramSocket? _udpSocket;
@ -52,12 +53,11 @@ class _PlayerPageState extends State<PlayerPage> {
Timer? _connectionHealthTimer; Timer? _connectionHealthTimer;
DateTime? _lastMessageReceivedTime; DateTime? _lastMessageReceivedTime;
String _fpsInfo = 'FPS: N/A';
String _bitrateInfo = '速率: N/A'; String _bitrateInfo = '速率: N/A';
Timer? _infoTimer; Timer? _infoTimer;
double maxPawRateScale = 1.0; double maxPawRateScale = 0.35;
Set<double> _pawScaleSelection = {1.0}; Set<double> _pawScaleSelection = {0.35};
@override @override
void initState() { void initState() {
@ -196,16 +196,10 @@ class _PlayerPageState extends State<PlayerPage> {
final nativePlayer = player.platform as NativePlayer; final nativePlayer = player.platform as NativePlayer;
try { try {
final bitrate = await nativePlayer.getProperty('video-bitrate'); final bitrate = await nativePlayer.getProperty('video-bitrate');
final fps = await nativePlayer.getProperty('video-fps');
if (mounted) { if (mounted) {
setState(() { setState(() {
final bitrateInKbps = (double.tryParse(bitrate) ?? 0) / 1024; final bitrateInKbps = (double.tryParse(bitrate) ?? 0) / 1024;
_bitrateInfo = '速率: ${bitrateInKbps.toStringAsFixed(0)} Kbps'; _bitrateInfo = '速率: ${bitrateInKbps.toStringAsFixed(0)} Kbps';
final double? fpsValue = double.tryParse(fps);
if (fpsValue != null) {
_fpsInfo = 'FPS: ${fpsValue.toStringAsFixed(1)}';
}
}); });
} }
} catch (e) { } catch (e) {
@ -317,7 +311,7 @@ class _PlayerPageState extends State<PlayerPage> {
final avgLatency = _latencyReadings.reduce((a, b) => a + b) / _latencyReadings.length; final avgLatency = _latencyReadings.reduce((a, b) => a + b) / _latencyReadings.length;
if (mounted) { if (mounted) {
setState(() { setState(() {
_pingResult = '图传延迟: ${avgLatency.toStringAsFixed(0)} ms'; _pingResult = '图传信号延迟: ${avgLatency.toStringAsFixed(0)} ms';
_videoLatencyHistory.add(FlSpot(DateTime.now().millisecondsSinceEpoch.toDouble(), avgLatency)); _videoLatencyHistory.add(FlSpot(DateTime.now().millisecondsSinceEpoch.toDouble(), avgLatency));
if (_videoLatencyHistory.length > _maxHistoryCount) { if (_videoLatencyHistory.length > _maxHistoryCount) {
@ -412,7 +406,7 @@ class _PlayerPageState extends State<PlayerPage> {
left: 0, left: 0,
child: Center( child: Center(
child: Container( child: Container(
width: 300, width: 400,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -422,7 +416,7 @@ class _PlayerPageState extends State<PlayerPage> {
collapsedIconColor: Colors.white, collapsedIconColor: Colors.white,
iconColor: Colors.white, iconColor: Colors.white,
title: Text( title: Text(
'$_pingResult $_bitrateInfo $_fpsInfo\n$_controlLatencyResult', '$_pingResult $_bitrateInfo\n$_controlLatencyResult',
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -438,7 +432,7 @@ class _PlayerPageState extends State<PlayerPage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
height: 120, height: 80,
child: LineChart( child: LineChart(
LineChartData( LineChartData(
lineBarsData: [ lineBarsData: [
@ -505,7 +499,7 @@ class _PlayerPageState extends State<PlayerPage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
height: 120, height: 80,
child: LineChart( child: LineChart(
LineChartData( LineChartData(
lineBarsData: [ lineBarsData: [
@ -569,162 +563,88 @@ class _PlayerPageState extends State<PlayerPage> {
Positioned( Positioned(
left: 64, left: 64,
bottom: 32, bottom: 32,
child: Joystick( child: SizedBox(
includeInitialAnimation: false, width: 160,
period: Duration(milliseconds: callBackPeriod), height: 160,
stick: CircleAvatar( child: SquareJoystick(
radius: 20, onChanged: (Offset position) {
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: JoystickSquareBase(
size: 160,
decoration: JoystickBaseDecoration(
color: Color.fromARGB(60, 200, 200, 200),
)
),
stickOffsetCalculator: const RectangleStickOffsetCalculator(),
listener: (details) {
setState(() { setState(() {
lx = details.x * 127 + 127.5; lx = position.dx * 127 + 127.5;
ly = details.y * 127 + 127.5; ly = position.dy * 127 + 127.5;
}); });
}, },
), ),
), ),
),
Positioned( Positioned(
right: 128, right: 128,
bottom: 180, bottom: 180,
child: Joystick( child: SizedBox(
includeInitialAnimation: false,
period: Duration(milliseconds: callBackPeriod),
stick: CircleAvatar(
radius: 20,
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: Container(
width: 160, width: 160,
height: 160, height: 160,
decoration: BoxDecoration( child: Joystick2D(
color: Color.fromARGB(60, 200, 200, 200), onChanged: (Offset position) {
shape: BoxShape.circle, if(position.dx == 0 && position.dy == 0) {
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1
)
),
),
listener: (details) {
if(details.x == 0 && details.y == 0) {
left = right = forward = backward = 0; left = right = forward = backward = 0;
} else { } else {
var angle = -atan2(details.y, details.x), pi_18 = pi / 18.0; var angle = -atan2(position.dy, position.dx), pi_18 = pi / 18.0;
setState(() { setState(() {
left = angle < - pi_18 * 12 || angle > pi_18 * 12 ? details.x * maxPawRateScale : 0; left = angle < - pi_18 * 12 || angle > pi_18 * 12 ? position.dx * maxPawRateScale : 0;
right = angle > - pi_18 * 6 && angle < pi_18 * 6 ? details.x * maxPawRateScale : 0; right = angle > - pi_18 * 6 && angle < pi_18 * 6 ? position.dx * maxPawRateScale : 0;
forward = angle > pi_18 * 3 && angle < pi_18 * 15 ? -details.y * maxPawRateScale : 0; forward = angle > pi_18 * 3 && angle < pi_18 * 15 ? -position.dy * maxPawRateScale : 0;
backward = angle > - pi_18 * 15 && angle < - pi_18 * 3 ? -details.y * maxPawRateScale : 0; backward = angle > - pi_18 * 15 && angle < - pi_18 * 3 ? -position.dy * maxPawRateScale : 0;
}); });
} }
}, },
), ),
), ),
),
Positioned( Positioned(
bottom: 180, bottom: 180,
right: 64, right: 54,
child: Joystick( child: SizedBox(
includeInitialAnimation: false, width: 60,
period: Duration(milliseconds: callBackPeriod),
mode: JoystickMode.vertical,
stick: CircleAvatar(
radius: 20,
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: Container(
width: 32,
height: 160, height: 160,
decoration: BoxDecoration( child: VerticalJoystick(
color: Color.fromARGB(60, 255, 255, 255), onChanged: (double y) {
borderRadius: const BorderRadius.all(
Radius.circular(20)
),
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1
)
)
),
listener: (details) {
setState(() { setState(() {
up = details.y < 0 ? -details.y * maxPawRateScale * 0.6 : 0; up = y < 0 ? -y * maxPawRateScale * 0.6 : 0;
down = details.y > 0 ? -details.y * maxPawRateScale * 0.6 : 0; down = y > 0 ? -y * maxPawRateScale * 0.6 : 0;
}); });
}, },
), ),
), ),
),
Positioned( Positioned(
bottom: 100, bottom: 90,
right: 64, right: 64,
child: Joystick( child: SizedBox(
includeInitialAnimation: false,
period: Duration(milliseconds: callBackPeriod),
mode: JoystickMode.horizontal,
stick: CircleAvatar(
radius: 20,
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: Container(
width: 160, width: 160,
height: 32, height: 60,
decoration: BoxDecoration( child: HorizontalJoystick(
color: Color.fromARGB(60, 255, 255, 255), onChanged: (double x) {
borderRadius: const BorderRadius.all(
Radius.circular(20)
),
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1
)
)
),
listener: (details) {
setState(() { setState(() {
ry = details.x * 127 + 127.5; ry = x * 127 + 127.5;
}); });
}, },
), ),
), ),
),
Positioned( Positioned(
bottom: 40, bottom: 30,
right: 64, right: 64,
child: Joystick( child: SizedBox(
includeInitialAnimation: false,
period: Duration(milliseconds: callBackPeriod),
mode: JoystickMode.horizontal,
stick: CircleAvatar(
radius: 20,
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: Container(
width: 160, width: 160,
height: 32, height: 60,
decoration: BoxDecoration( child: HorizontalJoystick(
color: Color.fromARGB(60, 255, 255, 255), onChanged: (double x) {
borderRadius: const BorderRadius.all(
Radius.circular(20)
),
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1
)
)
),
listener: (details) {
setState(() { setState(() {
rx = details.x * 127 / 2 + 127.5; rx = x * 127 / 2 + 127.5;
}); });
}, },
), ),
), ),
),
Positioned( Positioned(
bottom: 30, bottom: 30,
right: 256, right: 256,
@ -799,9 +719,9 @@ class _PlayerPageState extends State<PlayerPage> {
child: SegmentedButton<double>( child: SegmentedButton<double>(
segments: const <ButtonSegment<double>>[ segments: const <ButtonSegment<double>>[
ButtonSegment<double>(value: 0.05, label: Text('精确')), ButtonSegment<double>(value: 0.05, label: Text('精确')),
ButtonSegment<double>(value: 0.2, label: Text('缓慢')), ButtonSegment<double>(value: 0.10, label: Text('缓慢')),
ButtonSegment<double>(value: 0.5, label: Text('默认')), ButtonSegment<double>(value: 0.35, label: Text('默认')),
ButtonSegment<double>(value: 1.0, label: Text('狂暴')), ButtonSegment<double>(value: 0.5, label: Text('狂暴')),
], ],
selected: _pawScaleSelection, selected: _pawScaleSelection,
onSelectionChanged: (Set<double> newSelection) { onSelectionChanged: (Set<double> newSelection) {

View File

@ -0,0 +1,478 @@
import 'package:flutter/material.dart';
class Joystick2D extends StatefulWidget {
final ValueChanged<Offset> onChanged;
const Joystick2D({
super.key,
required this.onChanged,
});
@override
State<Joystick2D> createState() => _Joystick2DState();
}
class _Joystick2DState extends State<Joystick2D> {
Offset _stickPosition = Offset.zero;
bool _active = false;
void _onPanUpdate(Offset localPosition, Size size) {
final center = size.center(Offset.zero);
final delta = localPosition - center;
final distance = delta.distance;
final maxDistance = size.width / 2;
final constrainedDelta = distance > maxDistance
? Offset(
delta.dx * maxDistance / distance,
delta.dy * maxDistance / distance,
)
: delta;
setState(() {
_stickPosition = constrainedDelta;
_active = true;
});
final normalized = Offset(
constrainedDelta.dx / maxDistance,
constrainedDelta.dy / maxDistance,
);
widget.onChanged(normalized);
}
void _resetJoystick() {
setState(() {
_stickPosition = Offset.zero;
_active = false;
});
widget.onChanged(Offset.zero);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_onPanUpdate(localPosition, box.size);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_onPanUpdate(localPosition, box.size);
},
onPanEnd: (_) => _resetJoystick(),
onPanCancel: _resetJoystick,
child: CustomPaint(
painter: JoystickPainter(
stickPosition: _stickPosition,
active: _active,
),
),
);
}
}
class JoystickPainter extends CustomPainter {
final Offset stickPosition;
final bool active;
JoystickPainter({required this.stickPosition, required this.active});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.width / 2;
const stickRadius = 20.0;
canvas.drawCircle(
center,
radius,
Paint()
..color = active ? Colors.white.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.1)
..style = PaintingStyle.fill,
);
canvas.drawCircle(
center,
radius,
Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 2.0,
);
canvas.drawCircle(
center + stickPosition,
stickRadius,
Paint()
..color = active ? Colors.white : Colors.grey
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(JoystickPainter oldDelegate) {
return stickPosition != oldDelegate.stickPosition || active != oldDelegate.active;
}
}
class SquareJoystick extends StatefulWidget {
final ValueChanged<Offset> onChanged;
const SquareJoystick({
super.key,
required this.onChanged,
});
@override
State<SquareJoystick> createState() => _SquareJoystickState();
}
class _SquareJoystickState extends State<SquareJoystick> {
Offset _stickPosition = Offset.zero;
bool _active = false;
void _onPanUpdate(Offset localPosition, Size size) {
final center = size.center(Offset.zero);
var dx = localPosition.dx - center.dx;
var dy = localPosition.dy - center.dy;
final maxDistance = size.width / 2;
dx = dx.clamp(-maxDistance, maxDistance);
dy = dy.clamp(-maxDistance, maxDistance);
setState(() {
_stickPosition = Offset(dx, dy);
_active = true;
});
final normalized = Offset(
dx / maxDistance,
dy / maxDistance,
);
widget.onChanged(normalized);
}
void _resetJoystick() {
setState(() {
_stickPosition = Offset.zero;
_active = false;
});
widget.onChanged(Offset.zero);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_onPanUpdate(localPosition, box.size);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_onPanUpdate(localPosition, box.size);
},
onPanEnd: (_) => _resetJoystick(),
onPanCancel: _resetJoystick,
child: CustomPaint(
painter: SquareJoystickPainter(
stickPosition: _stickPosition,
active: _active,
),
),
);
}
}
class SquareJoystickPainter extends CustomPainter {
final Offset stickPosition;
final bool active;
SquareJoystickPainter({required this.stickPosition, required this.active});
@override
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
const stickRadius = 20.0;
final squareRect = Rect.fromCenter(
center: center,
width: size.width,
height: size.height,
);
final roundedRect = RRect.fromRectAndRadius(
squareRect,
Radius.circular(size.width / 8),
);
canvas.drawRRect(
roundedRect,
Paint()
..color =
active ? Colors.white.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.1)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
roundedRect,
Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 2.0,
);
canvas.drawCircle(
center + stickPosition,
stickRadius,
Paint()
..color = active ? Colors.white : Colors.grey
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(SquareJoystickPainter oldDelegate) {
return stickPosition != oldDelegate.stickPosition || active != oldDelegate.active;
}
}
class HorizontalJoystick extends StatefulWidget {
final ValueChanged<double> onChanged;
const HorizontalJoystick({
super.key,
required this.onChanged,
});
@override
State<HorizontalJoystick> createState() => _HorizontalJoystickState();
}
class _HorizontalJoystickState extends State<HorizontalJoystick> {
double _stickPosition = 0.0;
bool _active = false;
void _onPanUpdate(Offset localPosition, Size size) {
final centerX = size.width / 2;
final dx = (localPosition.dx - centerX).clamp(-centerX, centerX);
setState(() {
_stickPosition = dx;
_active = true;
});
final normalized = dx / centerX;
widget.onChanged(normalized);
}
void _resetJoystick() {
setState(() {
_stickPosition = 0.0;
_active = false;
});
widget.onChanged(0.0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
final box = context.findRenderObject() as RenderBox;
_onPanUpdate(box.globalToLocal(details.globalPosition), box.size);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
_onPanUpdate(box.globalToLocal(details.globalPosition), box.size);
},
onPanEnd: (_) => _resetJoystick(),
onPanCancel: _resetJoystick,
child: CustomPaint(
painter: HorizontalJoystickPainter(
stickPosition: _stickPosition,
active: _active,
),
),
);
}
}
class HorizontalJoystickPainter extends CustomPainter {
final double stickPosition;
final bool active;
HorizontalJoystickPainter({required this.stickPosition, required this.active});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
const stickRadius = 15.0;
final trackHeight = size.height / 2;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: center,
width: size.width,
height: trackHeight,
),
Radius.circular(trackHeight / 2),
),
Paint()
..color = active ? Colors.white.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.1),
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: center,
width: size.width,
height: trackHeight,
),
Radius.circular(trackHeight / 2),
),
Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 2.0,
);
canvas.drawCircle(
Offset(center.dx + stickPosition, center.dy),
stickRadius * 1.333,
Paint()
..color = active ? Colors.white : Colors.grey
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(HorizontalJoystickPainter oldDelegate) {
return stickPosition != oldDelegate.stickPosition || active != oldDelegate.active;
}
}
class VerticalJoystick extends StatefulWidget {
final ValueChanged<double> onChanged;
const VerticalJoystick({
super.key,
required this.onChanged,
});
@override
State<VerticalJoystick> createState() => _VerticalJoystickState();
}
class _VerticalJoystickState extends State<VerticalJoystick> {
double _stickPosition = 0.0;
bool _active = false;
void _onPanUpdate(Offset localPosition, Size size) {
final centerY = size.height / 2;
final dy = (localPosition.dy - centerY).clamp(-centerY, centerY);
setState(() {
_stickPosition = dy;
_active = true;
});
final normalized = dy / centerY;
widget.onChanged(normalized);
}
void _resetJoystick() {
setState(() {
_stickPosition = 0.0;
_active = false;
});
widget.onChanged(0.0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
final box = context.findRenderObject() as RenderBox;
_onPanUpdate(box.globalToLocal(details.globalPosition), box.size);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
_onPanUpdate(box.globalToLocal(details.globalPosition), box.size);
},
onPanEnd: (_) => _resetJoystick(),
onPanCancel: _resetJoystick,
child: CustomPaint(
painter: VerticalJoystickPainter(
stickPosition: _stickPosition,
active: _active,
),
),
);
}
}
class VerticalJoystickPainter extends CustomPainter {
final double stickPosition;
final bool active;
VerticalJoystickPainter({required this.stickPosition, required this.active});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
const stickRadius = 15.0;
final trackWidth = size.width / 2;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: center,
width: trackWidth,
height: size.height,
),
Radius.circular(trackWidth / 2),
),
Paint()
..color = active ? Colors.white.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.1),
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: center,
width: trackWidth,
height: size.height,
),
Radius.circular(trackWidth / 2),
),
Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 2.0,
);
canvas.drawCircle(
Offset(center.dx, center.dy + stickPosition),
stickRadius * 1.333,
Paint()
..color = active ? Colors.white : Colors.grey
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(VerticalJoystickPainter oldDelegate) {
return stickPosition != oldDelegate.stickPosition || active != oldDelegate.active;
}
}

View File

@ -41,6 +41,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -142,6 +158,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -184,6 +208,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.5.4"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -565,6 +597,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.5.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.4" flutter: ">=3.27.4"

View File

@ -54,6 +54,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter_launcher_icons: "^0.14.4"
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@ -67,9 +68,8 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: assets:
# - images/a_dot_burr.jpeg - assets/images/
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marscar_controller/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}