Moto■Puck Arrows App (Flutter) — Complete Code
This PDF contains a complete Flutter app you can run on BOTH Android and iOS. It connects to your BLE
device and sends arrow commands like LEFT/RIGHT/STRAIGHT/UTURN/ARRIVED. This is an MVP
controller app (not full Google Maps routing).
What this app does
• Scan BLE devices
• Connect to your Moto■Puck
• Find a writable BLE characteristic
• Send text commands to the device: LEFT, RIGHT, STRAIGHT, UTURN, ARRIVED
• Optional: send SPEED:42, ETA:18
Before you start (requirements)
1) Install Flutter SDK
2) Install Android Studio (Android) and Xcode (iOS)
3) Use a real phone (BLE is unreliable on emulator)
4) Turn on Bluetooth + Location (Android needs it for scanning)
Flutter dependencies
We use flutter_blue_plus because it’s popular and works on both Android and iOS.
name: moto_puck_app
description: Arrows-only BLE controller app for Moto-Puck prototype
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
flutter_blue_plus: ^1.34.5
cupertino_icons: ^1.0.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
Android permissions (android/app/src/main/[Link])
<!-- Add inside <manifest> -->
<uses-permission android:name="[Link]" />
<uses-permission android:name="[Link].BLUETOOTH_ADMIN" />
<!-- Android 12+ -->
<uses-permission android:name="[Link].BLUETOOTH_SCAN" />
<uses-permission android:name="[Link].BLUETOOTH_CONNECT" />
<!-- Needed for BLE scan results on many Android versions -->
<uses-permission android:name="[Link].ACCESS_FINE_LOCATION" />
<!-- Optional: if you want scan to work without location accuracy prompt -->
<uses-permission android:name="[Link].ACCESS_COARSE_LOCATION" />
iOS permissions (ios/Runner/[Link])
<!-- Add inside <dict> -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to the Moto-Puck navigation display.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to connect to the Moto-Puck navigation display.</string>
MAIN APP CODE (lib/[Link])
import 'dart:async';
import 'dart:convert';
import 'package:flutter/[Link]';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
void main() {
[Link]();
runApp(const MotoPuckApp());
}
class MotoPuckApp extends StatelessWidget {
const MotoPuckApp({[Link]});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Moto-Puck Arrows',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: [Link],
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({[Link]});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final FlutterBluePlus _ble = [Link];
bool _isScanning = false;
List<ScanResult> _scanResults = [];
BluetoothDevice? _device;
BluetoothCharacteristic? _writeChar;
String _status = "Not connected";
String _lastSent = "-";
StreamSubscription<List<ScanResult>>? _scanSub;
StreamSubscription<BluetoothConnectionState>? _connSub;
@override
void dispose() {
_scanSub?.cancel();
_connSub?.cancel();
[Link]();
}
Future<void> _startScan() async {
setState(() {
_scanResults = [];
_isScanning = true;
_status = "Scanning...";
});
// Stop any previous scan
await [Link]();
_scanSub?.cancel();
_scanSub = [Link]((results) {
setState(() {
_scanResults = results;
});
});
// Start scan
await [Link](timeout: const Duration(seconds: 8));
setState(() {
_isScanning = false;
_status = "Scan complete. Tap a device to connect.";
});
}
Future<void> _connectToDevice(BluetoothDevice device) async {
setState(() {
_status = "Connecting to ${[Link]}...";
_device = device;
_writeChar = null;
});
try {
await [Link](timeout: const Duration(seconds: 12), autoConnect: false);
} catch (e) {
// If already connected, ignore
}
_connSub?.cancel();
_connSub = [Link]((state) async {
if (!mounted) return;
if (state == [Link]) {
setState(() {
_status = "Connected. Discovering services...";
});
await _discoverServices(device);
} else if (state == [Link]) {
setState(() {
_status = "Disconnected";
_writeChar = null;
});
}
});
// Immediately try discovery too
await _discoverServices(device);
}
Future<void> _discoverServices(BluetoothDevice device) async {
try {
final services = await [Link]();
BluetoothCharacteristic? found;
// We search for ANY writable characteristic (MVP)
for (final s in services) {
for (final c in [Link]) {
final canWrite = [Link] || [Link];
if (canWrite) {
found = c;
break;
}
}
if (found != null) break;
}
if (found == null) {
setState(() {
_status = "Connected, but no writable characteristic found.";
_writeChar = null;
});
return;
}
setState(() {
_writeChar = found;
_status = "Ready! Send arrows now.";
});
} catch (e) {
setState(() {
_status = "Service discovery failed: $e";
});
}
}
Future<void> _disconnect() async {
if (_device == null) return;
try {
await _device!.disconnect();
} catch (_) {}
setState(() {
_status = "Disconnected";
_writeChar = null;
_device = null;
});
}
Future<void> _sendCommand(String cmd) async {
if (_device == null || _writeChar == null) {
setState(() {
_status = "Not ready. Connect to device first.";
});
return;
}
try {
final bytes = [Link](cmd);
// Prefer writeWithoutResponse if available for speed
final withoutResponse = _writeChar!.[Link];
await _writeChar!.write(bytes, withoutResponse: withoutResponse);
setState(() {
_lastSent = cmd;
_status = "Sent: $cmd";
});
} catch (e) {
setState(() {
_status = "Send failed: $e";
});
}
}
Widget _deviceTile(ScanResult r) {
final name = [Link] ? [Link] : "(Unnamed)";
final id = [Link];
return Card(
child: ListTile(
title: Text(name),
subtitle: Text(id),
trailing: Text("${[Link]} dBm"),
onTap: () => _connectToDevice([Link]),
),
);
}
Widget _commandButton(String label, String cmd, IconData icon) {
return Expanded(
child: Padding(
padding: const [Link](6),
child: [Link](
style: [Link](
padding: const [Link](vertical: 14),
),
onPressed: () => _sendCommand(cmd),
icon: Icon(icon),
label: Text(label),
),
),
);
}
@override
Widget build(BuildContext context) {
final connectedName = _device?.platformName ?? "-";
return Scaffold(
appBar: AppBar(
title: const Text("Moto■Puck Arrows"),
actions: [
if (_device != null)
IconButton(
onPressed: _disconnect,
icon: const Icon(Icons.link_off),
tooltip: "Disconnect",
)
],
),
body: Padding(
padding: const [Link](12),
child: Column(
crossAxisAlignment: [Link],
children: [
Card(
child: Padding(
padding: const [Link](12),
child: Column(
crossAxisAlignment: [Link],
children: [
Text("Status: $_status", style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text("Connected device: $connectedName"),
const SizedBox(height: 6),
Text("Last sent: $_lastSent"),
],
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: [Link](
onPressed: _isScanning ? null : _startScan,
icon: const Icon([Link]),
label: Text(_isScanning ? "Scanning..." : "Scan BLE Devices"),
),
),
],
),
const SizedBox(height: 10),
Expanded(
child: _scanResults.isEmpty
? const Center(child: Text("No devices yet. Tap Scan."))
: [Link](
itemCount: _scanResults.length,
itemBuilder: (context, i) => _deviceTile(_scanResults[i]),
),
),
const SizedBox(height: 10),
Card(
child: Padding(
padding: const [Link](12),
child: Column(
children: [
const Text("Send Arrow Commands", style: TextStyle(fontWeight: [Link])
const SizedBox(height: 10),
Row(
children: [
_commandButton("Left", "LEFT", Icons.turn_left),
_commandButton("Right", "RIGHT", Icons.turn_right),
],
),
Row(
children: [
_commandButton("Straight", "STRAIGHT", [Link]),
_commandButton("U■Turn", "UTURN", Icons.u_turn_left),
],
),
Row(
children: [
_commandButton("Arrived", "ARRIVED", [Link]),
_commandButton("Speed 40", "SPEED:40", [Link]),
],
),
],
),
),
),
],
),
),
);
}
}
How to run it (quick steps)
1) Create project:
flutter create moto_puck_app
2) Replace [Link] dependencies (add flutter_blue_plus)
3) Run:
flutter pub get
4) Replace lib/[Link] with the code in this PDF
5) Android:
flutter run
6) iOS:
cd ios && pod install && cd ..
flutter run
Important notes (so it actually works)
• On Android 12+, Bluetooth permissions are strict. If scan shows nothing, your phone may be blocking
permissions.
• Use a real phone, not emulator.
• Your device firmware must expose a BLE service with a writable characteristic. This app automatically finds
the first writable characteristic (simple MVP).
• If you want a clean product later, we will lock to a specific Service UUID and Characteristic UUID.
Next upgrade (recommended)
If you want the app to do real navigation: we will integrate a routing engine and then send the turn
instructions to the puck. For India-only, we can use Google Maps directions API (paid) or OpenStreetMap
based routing.