summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/arrows.dart57
-rw-r--r--lib/esense_connect_dialog.dart52
-rw-r--r--lib/level.dart250
-rw-r--r--lib/level_selection.dart208
-rw-r--r--lib/main.dart2
-rw-r--r--lib/models/arrow_direction.dart12
-rw-r--r--lib/models/input_direction.dart14
-rw-r--r--lib/models/note.dart10
-rw-r--r--lib/screens/game_over.dart (renamed from lib/game_over_stats.dart)10
-rw-r--r--lib/screens/level.dart285
-rw-r--r--lib/screens/level_selection.dart161
-rw-r--r--lib/utils/esense_input.dart188
-rw-r--r--lib/utils/simfile.dart (renamed from lib/simfile.dart)68
-rw-r--r--lib/widgets/arrow.dart18
-rw-r--r--lib/widgets/arrows.dart23
-rw-r--r--lib/widgets/connection_status_button.dart40
-rw-r--r--lib/widgets/esense_connect_dialog.dart64
-rw-r--r--lib/widgets/esense_not_connected_dialog.dart34
-rw-r--r--lib/widgets/level_info_chip.dart37
-rw-r--r--lib/widgets/level_list_entry.dart95
20 files changed, 1036 insertions, 592 deletions
diff --git a/lib/arrows.dart b/lib/arrows.dart
deleted file mode 100644
index ff53e02..0000000
--- a/lib/arrows.dart
+++ /dev/null
@@ -1,57 +0,0 @@
-import 'package:flutter/material.dart';
-
-enum ArrowDirection {
- left(Icons.arrow_back),
- down(Icons.arrow_downward),
- up(Icons.arrow_upward),
- right(Icons.arrow_forward);
-
- const ArrowDirection(this.icon);
-
- final IconData icon;
-}
-
-class Note {
- final double time;
- final ArrowDirection direction;
- double position = 0;
- bool? wasHit;
-
- Note({required this.time, required this.direction});
-}
-
-class Arrows extends StatelessWidget {
- final List<Note> notes;
- final double position;
-
- const Arrows({super.key, required this.notes, required this.position});
-
- @override
- Widget build(BuildContext context) {
- return Stack(
- children: notes.map((note) {
- double position = note.position * 10000; // * 20 * MediaQuery.of(context).size.height;
-
- return Arrow(
- position: position,
- direction: note.direction,
- );
- }).toList());
- }
-}
-
-class Arrow extends StatelessWidget {
- final double position;
- final ArrowDirection direction;
-
- const Arrow({super.key, required this.position, required this.direction});
-
- @override
- Widget build(BuildContext context) {
- return Positioned(
- left: MediaQuery.of(context).size.width / 2 - 50, // Center the arrow
- bottom: position + 50,
- child: Icon(size: 100, color: Colors.redAccent.shade400, direction.icon),
- );
- }
-}
diff --git a/lib/esense_connect_dialog.dart b/lib/esense_connect_dialog.dart
deleted file mode 100644
index b598174..0000000
--- a/lib/esense_connect_dialog.dart
+++ /dev/null
@@ -1,52 +0,0 @@
-import 'package:flutter/material.dart';
-
-class ESenseConnectDialog extends StatefulWidget {
- final void Function(String) connect;
- final ValueNotifier<String> deviceStatus;
- const ESenseConnectDialog(
- {super.key, required this.deviceStatus, required this.connect});
-
- @override
- State<ESenseConnectDialog> createState() => _ESenseConnectDialogState();
-}
-
-class _ESenseConnectDialogState extends State<ESenseConnectDialog> {
- String eSenseDeviceName = '';
-
- @override
- Widget build(BuildContext context) {
- return AlertDialog(
- title: const Text('Connect to ESense'),
- content: Column(mainAxisSize: MainAxisSize.min, children: [
- TextField(
- onChanged: (input) {
- setState(() {
- eSenseDeviceName = input;
- });
- },
- decoration: InputDecoration(
- border: OutlineInputBorder(),
- hintText: 'eSense-xxxx',
- labelText: 'Device name',
- ),
- ),
- // Text(eSenseDeviceName),
- ValueListenableBuilder(
- valueListenable: widget.deviceStatus,
- builder: (BuildContext context, String value, Widget? child) {
- return Text(value);
- }),
- ]),
- actions: <Widget>[
- TextButton(
- onPressed: () => Navigator.pop(context, 'Cancel'),
- child: const Text('Discard'),
- ),
- TextButton(
- onPressed: () => widget.connect(eSenseDeviceName),
- child: const Text('Connect'),
- ),
- ],
- );
- }
-}
diff --git a/lib/level.dart b/lib/level.dart
deleted file mode 100644
index cc17efb..0000000
--- a/lib/level.dart
+++ /dev/null
@@ -1,250 +0,0 @@
-import 'dart:async';
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-import 'package:audioplayers/audioplayers.dart';
-import 'package:flutter/services.dart';
-import 'package:sense_the_rhythm/arrows.dart';
-import 'package:sense_the_rhythm/game_over_stats.dart';
-import 'package:sense_the_rhythm/simfile.dart';
-
-class Level extends StatefulWidget {
- const Level({super.key, required this.stepmaniaFolderPath});
- final String stepmaniaFolderPath;
-
- @override
- State<Level> createState() => _LevelState();
-}
-
-class InputDirection {
- bool up = false;
- bool down = false;
- bool left = false;
- bool right = false;
-}
-
-class _LevelState extends State<Level> {
- final player = AudioPlayer();
- Simfile? simfile;
- bool _isPlaying = true;
- Duration? _duration;
- Duration? _position;
-
- StreamSubscription? _durationSubscription;
- StreamSubscription? _positionSubscription;
-
- final FocusNode _focusNode = FocusNode();
- InputDirection inputDirection = InputDirection();
-
- List<Note> notes = [];
-
- @override
- void setState(VoidCallback fn) {
- // Subscriptions only can be closed asynchronously,
- // therefore events can occur after widget has been disposed.
- if (mounted) {
- super.setState(fn);
- }
- }
-
- @override
- void initState() {
- super.initState();
- // Use initial values from player
- player.getDuration().then(
- (value) => setState(() {
- _duration = value;
- }),
- );
- player.getCurrentPosition().then(
- (value) => setState(() {
- _position = value;
- }),
- );
- _durationSubscription = player.onDurationChanged.listen((duration) {
- setState(() => _duration = duration);
- });
-
- _positionSubscription = player.onPositionChanged.listen(
- (p) => setState(() => _position = p),
- );
-
- player.onDurationChanged.listen((Duration d) {
- // print('Max duration: $d');
- setState(() => _duration = d);
- });
-
- player.onPlayerComplete.listen((void _) {
- Route route = MaterialPageRoute(
- builder: (context) => GameOverStats(
- simfile: simfile!,
- notes: notes,
- ));
- Navigator.pushReplacement(context, route);
- });
-
- player.onPositionChanged.listen((Duration p) {
- // print('Current position: $p');
- setState(() => _position = p);
- for (final note in notes) {
- note.position = note.time - p.inMilliseconds / 60000.0;
- if (note.wasHit != null) {
- continue;
- }
- if (note.position.abs() < 0.5 * 1.0 / 60.0) {
- bool keypressCorrect = false;
- switch (note.direction) {
- case ArrowDirection.up:
- keypressCorrect = inputDirection.up;
- break;
- case ArrowDirection.down:
- keypressCorrect = inputDirection.down;
- break;
- case ArrowDirection.right:
- keypressCorrect = inputDirection.right;
- break;
- case ArrowDirection.left:
- keypressCorrect = inputDirection.left;
- break;
- }
- if (keypressCorrect) {
- print("you hit!");
- note.wasHit = true;
-
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text('Great!'),
- duration: Duration(milliseconds: 500),
- ),
- );
- }
- } else if (note.position < -0.5 * 1.0 / 60.0) {
- print("Missed");
- note.wasHit = false;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text('Missed!'),
- duration: Duration(milliseconds: 500),
- ),
- );
- }
- }
- });
-
- simfile = Simfile(widget.stepmaniaFolderPath);
- simfile!.load();
-
- simfile!.chartSimplest!.beats.forEach((time, noteData) {
- int arrowIndex = noteData.indexOf('1');
- if (arrowIndex < 0 || arrowIndex > 3) {
- return;
- }
- notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex]));
- });
-
- player.play(DeviceFileSource(simfile!.audioPath!));
- }
-
- @override
- Widget build(BuildContext context) {
- return KeyboardListener(
- focusNode: _focusNode,
- autofocus: true,
- onKeyEvent: (event) {
- bool isDown = false;
- if (event is KeyDownEvent) {
- isDown = true;
- } else if (event is KeyUpEvent) {
- isDown = false;
- } else {
- return;
- }
- switch (event.logicalKey) {
- case LogicalKeyboardKey.arrowUp:
- inputDirection.up = isDown;
- break;
- case LogicalKeyboardKey.arrowDown:
- inputDirection.down = isDown;
- break;
- case LogicalKeyboardKey.arrowLeft:
- inputDirection.left = isDown;
- break;
- case LogicalKeyboardKey.arrowRight:
- inputDirection.right = isDown;
- break;
- }
- },
- child: Scaffold(
- appBar: AppBar(
- leading: IconButton(
- icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
- onPressed: () {
- if (_isPlaying) {
- player.pause();
- setState(() {
- _isPlaying = false;
- });
- } else {
- player.resume();
- setState(() {
- _isPlaying = true;
- });
- }
- },
- ),
- title: Text(widget.stepmaniaFolderPath.split('/').last),
- actions: [
- IconButton(
- icon: Icon(Icons.close),
- onPressed: () => Navigator.pop(context))
- ],
- bottom: PreferredSize(
- preferredSize: Size(double.infinity, 1.0),
- child: LinearProgressIndicator(
- value: (_duration != null &&
- _position != null &&
- _position!.inMilliseconds > 0 &&
- _position!.inMilliseconds < _duration!.inMilliseconds)
- ? _position!.inMilliseconds / _duration!.inMilliseconds
- : 0.0,
- )),
- ),
- body: Stack(children: [
- Arrows(
- notes: notes,
- position: _position != null
- ? _position!.inMilliseconds.toDouble()
- : 0.0),
- Positioned(
- top: 50,
- width: MediaQuery.of(context).size.width,
- left: 0,
- child: Text(
- "Great!",
- textScaler: TextScaler.linear(4),
- textAlign: TextAlign.center,
- ),
- ),
- Positioned(
- left: MediaQuery.of(context).size.width / 2 - 50,
- bottom: 50,
- child: Container(
- width: 100,
- height: 100,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- border: Border.all(color: Colors.black, width: 10)),
- ),
- ),
- ])),
- );
- }
-
- @override
- void dispose() {
- _durationSubscription?.cancel();
- _positionSubscription?.cancel();
- player.dispose();
- super.dispose();
- }
-}
diff --git a/lib/level_selection.dart b/lib/level_selection.dart
deleted file mode 100644
index 5359d66..0000000
--- a/lib/level_selection.dart
+++ /dev/null
@@ -1,208 +0,0 @@
-import 'dart:io';
-
-import 'package:esense_flutter/esense.dart';
-import 'package:file_picker/file_picker.dart';
-import 'package:flutter/material.dart';
-import 'package:permission_handler/permission_handler.dart';
-import 'package:sense_the_rhythm/esense_connect_dialog.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-import 'level.dart';
-
-class LevelSelection extends StatefulWidget {
- const LevelSelection({super.key});
-
- @override
- State<LevelSelection> createState() => _LevelSelectionState();
-}
-
-class _LevelSelectionState extends State<LevelSelection> {
- String? stepmaniaCoursesPath;
- List<FileSystemEntity> stepmaniaCoursesFolders = [];
-
- String eSenseDeviceName = '';
- ESenseManager? eSenseManager;
- ValueNotifier<String> _deviceStatus = ValueNotifier('');
- // String _deviceStatus = '';
- bool connected = false;
- bool sampling = false;
-
- @override
- void initState() {
- super.initState();
- _listenToESense();
- loadFolderPath();
- }
-
- Future<void> _askForPermissions() async {
- if (!(await Permission.bluetoothScan.request().isGranted &&
- await Permission.bluetoothConnect.request().isGranted)) {
- print(
- 'WARNING - no permission to use Bluetooth granted. Cannot access eSense device.');
- }
- // for some strange reason, Android requires permission to location for Bluetooth to work.....?
- if (Platform.isAndroid) {
- if (!(await Permission.locationWhenInUse.request().isGranted)) {
- print(
- 'WARNING - no permission to access location granted. Cannot access eSense device.');
- }
- }
- }
-
- Future<void> _listenToESense() async {
- await _askForPermissions();
-
- // if you want to get the connection events when connecting,
- // set up the listener BEFORE connecting...
- eSenseManager!.connectionEvents.listen((event) {
- print('CONNECTION event: $event');
-
- // when we're connected to the eSense device, we can start listening to events from it
- // if (event.type == ConnectionType.connected) _listenToESenseEvents();
-
- setState(() {
- connected = false;
- switch (event.type) {
- case ConnectionType.connected:
- _deviceStatus.value = 'connected';
- connected = true;
- break;
- case ConnectionType.unknown:
- _deviceStatus.value = 'unknown';
- break;
- case ConnectionType.disconnected:
- _deviceStatus.value = 'disconnected';
- sampling = false;
- break;
- case ConnectionType.device_found:
- _deviceStatus.value = 'device_found';
- break;
- case ConnectionType.device_not_found:
- _deviceStatus.value = 'device_not_found';
- break;
- }
- });
- });
- }
-
- Future<void> _connectToESense(String deviceName) async {
- if (!connected) {
- await _askForPermissions();
- print('Trying to connect to eSense device...');
- setState(() {
- eSenseDeviceName = deviceName;
- });
- print(eSenseDeviceName);
- eSenseManager = ESenseManager(eSenseDeviceName);
- connected = await eSenseManager!.connect();
- print('success!');
-
- setState(() {
- _deviceStatus.value = connected ? 'connecting...' : 'connection failed';
- });
- }
- }
-
- Future<void> loadFolderPath() async {
- SharedPreferences prefs = await SharedPreferences.getInstance();
- final String? stepmaniaCoursesPathSetting =
- prefs.getString('stepmania_courses');
-
- if (stepmaniaCoursesPathSetting == null) return;
- List<FileSystemEntity> stepmaniaCoursesFoldersFuture = await listFilesAndFolders(stepmaniaCoursesPathSetting);
-
- setState(() {
- stepmaniaCoursesPath = stepmaniaCoursesPathSetting;
- stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture;
- });
- }
-
- Future<void> selectFolder() async {
- String? selectedFolder = await FilePicker.platform.getDirectoryPath();
-
- if (selectedFolder != null) {
- // Save the selected folder path
- SharedPreferences prefs = await SharedPreferences.getInstance();
- await prefs.setString('stepmania_courses', selectedFolder);
-
- loadFolderPath();
- }
- }
-
- Future<List<FileSystemEntity>> listFilesAndFolders(
- String directoryPath) async {
- final directory = Directory(directoryPath);
- try {
- // List all files and folders in the directory
- return directory
- .listSync()
- .where((entity) => FileSystemEntity.isDirectorySync(entity.path))
- .toList();
- } catch (e) {
- print("Error reading directory: $e");
- return [];
- }
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(
- title: const Text('Sense the Rhythm'),
- actions: [
- IconButton(
- onPressed: () => showDialog(
- context: context,
- builder: (BuildContext context) {
- return ESenseConnectDialog(
- deviceStatus: _deviceStatus,
- connect: (String name) {
- _connectToESense(name);
- });
- },
- ),
- icon: const Icon(Icons.bluetooth))
- ],
- ),
- body: Builder(builder: (context) {
- if (stepmaniaCoursesPath == null) {
- return Text('Add a Directory with Stepmania Songs on \'+\'');
- } else if (stepmaniaCoursesFolders.isEmpty) {
- return Text(
- 'Folder empty. Add Stepmania Songs to Folder or select a different folder on \'+\'');
- } else {
- return ListView.separated(
- itemCount: stepmaniaCoursesFolders.length,
- separatorBuilder: (BuildContext context, int index) =>
- const Divider(),
- itemBuilder: (context, index) {
- String thumbnailPath = Directory(
- stepmaniaCoursesFolders[index].path)
- .listSync()
- .firstWhere(
- (file) => file.path.toLowerCase().endsWith('banner.png'),
- orElse: () => File(''))
- .path;
- return ListTile(
- leading: Image.file(File(thumbnailPath)),
- trailing: Icon(Icons.play_arrow),
- title:
- Text(stepmaniaCoursesFolders[index].path.split('/').last),
- subtitle: Text('3:45'),
- onTap: () => Navigator.push(
- context,
- MaterialPageRoute(
- builder: (BuildContext context) => Level(
- stepmaniaFolderPath:
- stepmaniaCoursesFolders[index].path,
- ))),
- );
- },
- );
- }
- }),
- floatingActionButton: FloatingActionButton(
- onPressed: () => {selectFolder()}, child: Icon(Icons.add)),
- );
- }
-}
diff --git a/lib/main.dart b/lib/main.dart
index eb01d44..85443bb 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import 'level_selection.dart';
+import 'package:sense_the_rhythm/screens/level_selection.dart';
void main() {
runApp(const MyApp());
diff --git a/lib/models/arrow_direction.dart b/lib/models/arrow_direction.dart
new file mode 100644
index 0000000..5f0298b
--- /dev/null
+++ b/lib/models/arrow_direction.dart
@@ -0,0 +1,12 @@
+import 'package:flutter/material.dart';
+
+enum ArrowDirection {
+ left(Icons.arrow_back),
+ down(Icons.arrow_downward),
+ up(Icons.arrow_upward),
+ right(Icons.arrow_forward);
+
+ const ArrowDirection(this.icon);
+
+ final IconData icon;
+}
diff --git a/lib/models/input_direction.dart b/lib/models/input_direction.dart
new file mode 100644
index 0000000..b15b880
--- /dev/null
+++ b/lib/models/input_direction.dart
@@ -0,0 +1,14 @@
+class InputDirection {
+ bool up = false;
+ bool down = false;
+ bool left = false;
+ bool right = false;
+
+ /// reset all directions to false
+ void reset() {
+ up = false;
+ down = false;
+ left = false;
+ right = false;
+ }
+}
diff --git a/lib/models/note.dart b/lib/models/note.dart
new file mode 100644
index 0000000..bcc6ac6
--- /dev/null
+++ b/lib/models/note.dart
@@ -0,0 +1,10 @@
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+
+class Note {
+ final double time;
+ final ArrowDirection direction;
+ double position = 0;
+ bool? wasHit;
+
+ Note({required this.time, required this.direction});
+}
diff --git a/lib/game_over_stats.dart b/lib/screens/game_over.dart
index a80617c..76ea2fb 100644
--- a/lib/game_over_stats.dart
+++ b/lib/screens/game_over.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
-import 'package:sense_the_rhythm/arrows.dart';
-import 'package:sense_the_rhythm/level.dart';
-import 'package:sense_the_rhythm/simfile.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/screens/level.dart';
class GameOverStats extends StatelessWidget {
const GameOverStats({super.key, required this.simfile, required this.notes});
@@ -50,8 +50,8 @@ class GameOverStats extends StatelessWidget {
]),
TextButton(
onPressed: () {
- Route route = MaterialPageRoute(
- builder: (context) => Level(stepmaniaFolderPath: simfile.directoryPath));
+ Route route =
+ MaterialPageRoute(builder: (context) => Level(simfile));
Navigator.pushReplacement(context, route);
},
child: Text('Retry'))
diff --git a/lib/screens/level.dart b/lib/screens/level.dart
new file mode 100644
index 0000000..000c43c
--- /dev/null
+++ b/lib/screens/level.dart
@@ -0,0 +1,285 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter/services.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+import 'package:sense_the_rhythm/models/input_direction.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/widgets/arrows.dart';
+import 'package:sense_the_rhythm/screens/game_over.dart';
+
+class Level extends StatefulWidget {
+ const Level(this.simfile, {super.key});
+ final Simfile simfile;
+
+ @override
+ State<Level> createState() => _LevelState();
+}
+
+class _LevelState extends State<Level> with SingleTickerProviderStateMixin {
+ final _player = AudioPlayer();
+ bool _isPlaying = true;
+ Duration? _duration;
+ Duration? _position;
+
+ StreamSubscription? _durationSubscription;
+ StreamSubscription? _positionSubscription;
+ StreamSubscription? _buttonSubscription;
+
+ final FocusNode _focusNode = FocusNode();
+ final InputDirection _inputDirection = InputDirection();
+
+ String _hitOrMissMessage = 'Play!';
+
+ final List<Note> _notes = [];
+
+ late AnimationController _animationController;
+ late Animation<double> _animation;
+
+ @override
+ void setState(VoidCallback fn) {
+ // Subscriptions only can be closed asynchronously,
+ // therefore events can occur after widget has been disposed.
+ if (mounted) {
+ super.setState(fn);
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ ESenseInput.instance.resetAngles();
+
+ _animationController = AnimationController(
+ vsync: this,
+ duration: Duration(seconds: 2),
+ );
+ _animation =
+ Tween<double>(begin: 1.0, end: 0.0).animate(_animationController);
+ _animationController.forward();
+
+ // Use initial values from player
+ _player.getDuration().then(
+ (value) => setState(() {
+ _duration = value;
+ }),
+ );
+ _player.getCurrentPosition().then(
+ (value) => setState(() {
+ _position = value;
+ }),
+ );
+
+ // listen for new values from player
+ _durationSubscription =
+ _player.onDurationChanged.listen((Duration duration) {
+ setState(() => _duration = duration);
+ });
+
+ _positionSubscription =
+ _player.onPositionChanged.listen((Duration position) {
+ setState(() => _position = position);
+ for (final note in _notes) {
+ _noteHitCheck(note, position);
+ }
+ });
+
+ // go to GameOverStats when level finishes
+ _player.onPlayerComplete.listen((void _) {
+ Route route = MaterialPageRoute(
+ builder: (context) => GameOverStats(
+ simfile: widget.simfile,
+ notes: _notes,
+ ));
+ Navigator.pushReplacement(context, route);
+ });
+
+ // listen for esense button and pause/resume
+ if (ESenseInput.instance.connected) {
+ _buttonSubscription = ESenseInput.instance.buttonEvents().listen((event) {
+ if (!event.pressed) {
+ _pauseResume();
+ }
+ });
+ }
+
+ // convert beats to notes
+ widget.simfile.chartSimplest?.beats.forEach((time, noteData) {
+ int arrowIndex = noteData.indexOf('1');
+ if (arrowIndex < 0 || arrowIndex > 3) {
+ return;
+ }
+ _notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex]));
+ });
+
+ _player.play(DeviceFileSource(widget.simfile.audioPath!));
+ }
+
+ @override
+ void dispose() {
+ _animationController.dispose();
+ _durationSubscription?.cancel();
+ _positionSubscription?.cancel();
+ _buttonSubscription?.cancel();
+ _player.dispose();
+ super.dispose();
+ }
+
+ /// toggle between pause and resume
+ void _pauseResume() {
+ if (_isPlaying) {
+ _player.pause();
+ setState(() {
+ _isPlaying = false;
+ });
+ } else {
+ _player.resume();
+ setState(() {
+ _isPlaying = true;
+ });
+ }
+ }
+
+ /// checks if the [note] is hit on [time] with the correct InputDirection
+ void _noteHitCheck(Note note, Duration time) {
+ note.position = note.time - time.inMilliseconds / 60000.0;
+ if (note.wasHit != null) {
+ return;
+ }
+
+ // you have +- half a second to hit
+ if (note.position.abs() < 0.5 * 1.0 / 60.0) {
+ // combine keyboard and esense input
+ InputDirection esenseDirection =
+ ESenseInput.instance.getInputDirection(note.direction);
+ _inputDirection.up |= esenseDirection.up;
+ _inputDirection.down |= esenseDirection.down;
+ _inputDirection.left |= esenseDirection.left;
+ _inputDirection.right |= esenseDirection.right;
+
+ // check if input matches arrow direction
+ bool keypressCorrect = false;
+ switch (note.direction) {
+ case ArrowDirection.up:
+ keypressCorrect = _inputDirection.up;
+ break;
+ case ArrowDirection.down:
+ keypressCorrect = _inputDirection.down;
+ break;
+ case ArrowDirection.right:
+ keypressCorrect = _inputDirection.right;
+ break;
+ case ArrowDirection.left:
+ keypressCorrect = _inputDirection.left;
+ break;
+ }
+ if (keypressCorrect) {
+ print("you hit!");
+ note.wasHit = true;
+ _animationController.reset();
+ _animationController.forward();
+ _inputDirection.reset();
+ setState(() {
+ _hitOrMissMessage = 'Great!';
+ });
+ }
+ } else if (note.position < -0.5 * 1.0 / 60.0) {
+ print("Missed");
+ note.wasHit = false;
+ _animationController.reset();
+ _animationController.forward();
+ _inputDirection.reset();
+ setState(() {
+ _hitOrMissMessage = 'Missed';
+ });
+ }
+ }
+
+ /// sets the InputDirection based on the arrow keys
+ void _keyboardHandler(event) {
+ bool isDown = false;
+ if (event is KeyDownEvent) {
+ isDown = true;
+ } else if (event is KeyUpEvent) {
+ isDown = false;
+ } else {
+ return;
+ }
+ switch (event.logicalKey) {
+ case LogicalKeyboardKey.arrowUp:
+ _inputDirection.up = isDown;
+ break;
+ case LogicalKeyboardKey.arrowDown:
+ _inputDirection.down = isDown;
+ break;
+ case LogicalKeyboardKey.arrowLeft:
+ _inputDirection.left = isDown;
+ break;
+ case LogicalKeyboardKey.arrowRight:
+ _inputDirection.right = isDown;
+ break;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return KeyboardListener(
+ focusNode: _focusNode,
+ autofocus: true,
+ onKeyEvent: _keyboardHandler,
+ child: Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
+ onPressed: _pauseResume,
+ ),
+ title: Text(widget.simfile.tags['TITLE']!),
+ actions: [
+ IconButton(
+ icon: Icon(Icons.close),
+ onPressed: () => Navigator.pop(context))
+ ],
+ bottom: PreferredSize(
+ preferredSize: Size(double.infinity, 1.0),
+ child: LinearProgressIndicator(
+ value: (_duration != null &&
+ _position != null &&
+ _position!.inMilliseconds > 0 &&
+ _position!.inMilliseconds < _duration!.inMilliseconds)
+ ? _position!.inMilliseconds / _duration!.inMilliseconds
+ : 0.0,
+ )),
+ ),
+ body: Stack(children: [
+ Arrows(notes: _notes),
+ Positioned(
+ top: 50,
+ width: MediaQuery.of(context).size.width,
+ left: 0,
+ child: FadeTransition(
+ opacity: _animation,
+ child: Text(
+ _hitOrMissMessage,
+ textScaler: TextScaler.linear(4),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ Positioned(
+ left: MediaQuery.of(context).size.width / 2 - 50,
+ bottom: 50,
+ child: Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.black, width: 10)),
+ ),
+ ),
+ ])),
+ );
+ }
+}
diff --git a/lib/screens/level_selection.dart b/lib/screens/level_selection.dart
new file mode 100644
index 0000000..0987063
--- /dev/null
+++ b/lib/screens/level_selection.dart
@@ -0,0 +1,161 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/widgets/connection_status_button.dart';
+import 'package:sense_the_rhythm/widgets/level_list_entry.dart';
+
+class LevelSelection extends StatefulWidget {
+ const LevelSelection({super.key});
+
+ @override
+ State<LevelSelection> createState() => _LevelSelectionState();
+}
+
+class _LevelSelectionState extends State<LevelSelection> {
+ String? _stepmaniaCoursesPath;
+ List<Simfile> _stepmaniaCoursesFolders = [];
+ List<Simfile> _stepmaniaCoursesFoldersFiltered = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _loadFolderPath();
+ }
+
+ /// gets folder path from persistent storage and updates state with loaded simfiles
+ Future<void> _loadFolderPath() async {
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ final String? stepmaniaCoursesPathSetting =
+ prefs.getString('stepmania_courses');
+
+ if (stepmaniaCoursesPathSetting == null) return;
+ List<Simfile> stepmaniaCoursesFoldersFuture =
+ await _listFilesAndFolders(stepmaniaCoursesPathSetting);
+
+ setState(() {
+ _stepmaniaCoursesPath = stepmaniaCoursesPathSetting;
+ _stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture;
+ _stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFoldersFuture;
+ });
+ }
+
+ /// open folder selection dialog and save selected folder in persistent storage
+ Future<void> _selectFolder() async {
+ await Permission.manageExternalStorage.request();
+ String? selectedFolder = await FilePicker.platform.getDirectoryPath();
+
+ if (selectedFolder != null) {
+ // Save the selected folder path
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ await prefs.setString('stepmania_courses', selectedFolder);
+
+ _loadFolderPath();
+ }
+ }
+
+ /// load all simfiles from a [directoryPath]
+ Future<List<Simfile>> _listFilesAndFolders(String directoryPath) async {
+ final directory = Directory(directoryPath);
+ try {
+ // List all files and folders in the directory
+ List<Simfile> simfiles = directory
+ .listSync(recursive: true)
+ .where((entity) => entity.path.endsWith('.sm'))
+ .map((entity) => Simfile(entity.path))
+ .toList();
+
+ List<bool> successfullLoads =
+ await Future.wait(simfiles.map((simfile) => simfile.load()));
+ List<Simfile> simfilesFiltered = [];
+ for (int i = 0; i < simfiles.length; i++) {
+ if (successfullLoads[i]) {
+ simfilesFiltered.add(simfiles[i]);
+ }
+ }
+
+ simfilesFiltered
+ .sort((a, b) => a.tags['TITLE']!.compareTo(b.tags['TITLE']!));
+
+ return simfilesFiltered;
+ } catch (e) {
+ print("Error reading directory: $e");
+ return [];
+ }
+ }
+
+ /// filter stepmaniaCoursesFolders based on [input]
+ void _filterLevels(String input) {
+ setState(() {
+ _stepmaniaCoursesFoldersFiltered = _stepmaniaCoursesFolders
+ .where((simfile) => simfile.tags["TITLE"]!
+ .toLowerCase()
+ .contains(input.toLowerCase()))
+ .toList();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Sense the Rhythm'),
+ actions: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0),
+ child: ValueListenableBuilder(
+ valueListenable: ESenseInput.instance.deviceStatus,
+ builder:
+ (BuildContext context, String deviceStatus, Widget? child) {
+ return ConnectionStatusButton(deviceStatus);
+ },
+ ),
+ )
+ ],
+ ),
+ body: Builder(builder: (context) {
+ if (_stepmaniaCoursesPath == null) {
+ return Text('Add a Directory with Stepmania Songs on \'+\'');
+ } else if (_stepmaniaCoursesFolders.isEmpty) {
+ return Text(
+ 'Folder empty. Add Stepmania Songs to Folder or select a different folder on \'+\'');
+ } else {
+ return Column(
+ children: [
+ Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0),
+ child: TextField(
+ onChanged: _filterLevels,
+ decoration: InputDecoration(
+ // icon: Icon(Icons.search),
+ hintText: 'Search'),
+ ),
+ ),
+ Expanded(
+ child: ListView.separated(
+ itemCount: _stepmaniaCoursesFoldersFiltered.length,
+ separatorBuilder: (BuildContext context, int index) =>
+ const Divider(),
+ itemBuilder: (context, index) {
+ Simfile simfile = _stepmaniaCoursesFoldersFiltered[index];
+ return LevelListEntry(simfile: simfile);
+ },
+ ),
+ ),
+ ],
+ );
+ }
+ }),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () {
+ _selectFolder();
+ },
+ child: Icon(Icons.add)),
+ );
+ }
+}
diff --git a/lib/utils/esense_input.dart b/lib/utils/esense_input.dart
new file mode 100644
index 0000000..08875d5
--- /dev/null
+++ b/lib/utils/esense_input.dart
@@ -0,0 +1,188 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:esense_flutter/esense.dart';
+import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+import 'package:sense_the_rhythm/models/input_direction.dart';
+
+class ESenseInput {
+ // create singleton that is available on all widgets so it does not have to be
+ // carried down in the widget tree
+ static final instance = ESenseInput._();
+
+ ESenseManager eSenseManager = ESenseManager('unknown');
+ // valuenotifier allows widgets to rerender when the value changes
+ ValueNotifier<String> deviceStatus = ValueNotifier('Disconnected');
+ StreamSubscription? _subscription;
+
+ String eSenseDeviceName = '';
+ bool connected = false;
+
+ final int _sampleRate = 20;
+
+ final InputDirection _inputDirection = InputDirection();
+ int _x = 0;
+ int _y = 0;
+ int _z = 0;
+
+ ESenseInput._() {
+ _listenToESense();
+ }
+
+ /// ask and check if permissions are enabled and granted
+ Future<bool> _askForPermissions() async {
+ // is desktop
+ if (!Platform.isAndroid && !Platform.isIOS) return false;
+ // is bluetooth even enabled?
+ if (!await Permission.bluetooth.serviceStatus.isEnabled) {
+ deviceStatus.value = "Bluetooth is disabled!";
+ return false;
+ }
+ if (!(await Permission.bluetoothScan.request().isGranted &&
+ await Permission.bluetoothConnect.request().isGranted &&
+ await Permission.bluetooth.request().isGranted)) {
+ print(
+ 'WARNING - no permission to use Bluetooth granted. Cannot access eSense device.');
+ deviceStatus.value = "Insufficiant Permissions";
+ return false;
+ }
+ // for some strange reason, Android requires permission to location for Bluetooth to work.....?
+ if (Platform.isAndroid) {
+ if (!(await Permission.locationWhenInUse.request().isGranted)) {
+ print(
+ 'WARNING - no permission to access location granted. Cannot access eSense device.');
+ deviceStatus.value = "Insufficiant Permissions";
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /// listen to connectionEvents and set deviceStatus
+ void _listenToESense() {
+ // if you want to get the connection events when connecting,
+ // set up the listener BEFORE connecting...
+ eSenseManager.connectionEvents.listen((event) {
+ print('CONNECTION event: $event');
+
+ // when we're connected to the eSense device, we can start listening to events from it
+ // if (event.type == ConnectionType.connected) _listenToESenseEvents();
+
+ connected = false;
+ switch (event.type) {
+ case ConnectionType.connected:
+ deviceStatus.value = 'Connected';
+ connected = true;
+ _startListenToSensorEvents();
+ break;
+ case ConnectionType.unknown:
+ deviceStatus.value = 'Unknown';
+ break;
+ case ConnectionType.disconnected:
+ deviceStatus.value = 'Disconnected';
+ _pauseListenToSensorEvents();
+ break;
+ case ConnectionType.device_found:
+ deviceStatus.value = 'Device_found';
+ break;
+ case ConnectionType.device_not_found:
+ deviceStatus.value = 'Device_not_found';
+ break;
+ }
+ });
+ }
+
+ /// get eSenseEvent stream only containung button events
+ Stream<ButtonEventChanged> buttonEvents() {
+ return eSenseManager.eSenseEvents
+ .where((event) => event.runtimeType == ButtonEventChanged)
+ .cast();
+ }
+
+ /// sets sampling rate and listens to sensorEvents
+ void _startListenToSensorEvents() async {
+ // // any changes to the sampling frequency must be done BEFORE listening to sensor events
+ print('setting sampling frequency...');
+ bool successs = await eSenseManager.setSamplingRate(_sampleRate);
+ if (successs) {
+ print('setSamplingRate success');
+ } else {
+ print('setSamplingRate fail');
+ }
+
+ // subscribe to sensor event from the eSense device
+ _subscription = eSenseManager.sensorEvents.listen((event) {
+ // print('SENSOR event: $event');
+ if (event.gyro != null) {
+ _parseGyroData(event.gyro!);
+ }
+ });
+ }
+
+ /// cancels the sensorEvents listening
+ void _pauseListenToSensorEvents() async {
+ _subscription?.cancel();
+ }
+
+ /// add up all new gyro [data] in the form of deg/s multiplied by scaling factor
+ /// to get real angles
+ void _parseGyroData(List<int> data) {
+ // Float value in deg/s = Gyro value / Gyro scale factor
+ // The default configuration is +- 500deg/s for the gyroscope.
+ _x = (_x + (15 * data[0] ~/ (500 * _sampleRate))) % 360;
+ _y = (_y + (15 * data[1] ~/ (500 * _sampleRate))) % 360;
+ _z = (_z + (15 * data[2] ~/ (500 * _sampleRate))) % 360;
+ print('$_x, $_y, $_z');
+ // print('${(z.toDouble() / 500.0 * (1.0 / sampleRate.toDouble())) * 7.5}');
+ // print('${z.toDouble() / 500.0 * (1.0 / 10.0)}');
+ }
+
+ /// nulls all angles and reset inputDirection
+ void resetAngles() {
+ _inputDirection.reset();
+ _x = 0;
+ _y = 0;
+ _z = 0;
+ }
+
+ /// get InputDirection by checking if angels are in defined ranges and
+ /// calibrating based on the [expect]ed direction from ArrowDirection
+ InputDirection getInputDirection(ArrowDirection expect) {
+ // check if angle is in range
+ _inputDirection.up = _z > 180 && _z < 340;
+ _inputDirection.down = _z > 20 && _z < 180;
+ _inputDirection.left = _y > 0 && _y < 180;
+ _inputDirection.right = _y > 180 && _y < 360;
+
+ // calibrate based on expected directoin from ArrowDirection
+ if (expect == ArrowDirection.up && _inputDirection.up ||
+ expect == ArrowDirection.down && _inputDirection.down) {
+ _y = 0;
+ }
+ if (expect == ArrowDirection.left && _inputDirection.left ||
+ expect == ArrowDirection.right && _inputDirection.right) {
+ _z = 0;
+ }
+
+ return _inputDirection;
+ }
+
+ /// connect to ESense with [deviceName] by first asking for permissions
+ Future<void> connectToESense(String deviceName) async {
+ if (!connected) {
+ bool permissionSuccessfull = await _askForPermissions();
+ if (!permissionSuccessfull) return;
+ print('Trying to connect to eSense device namend \'$deviceName\'');
+ eSenseDeviceName = deviceName;
+ eSenseManager.deviceName = deviceName;
+ bool connecting = await eSenseManager.connect();
+ print(
+ 'Trying to connect to eSense device namend \'${eSenseManager.deviceName}\'');
+
+ deviceStatus.value = connecting ? 'connecting...' : 'connection failed';
+ print(deviceStatus.value);
+ }
+ }
+}
diff --git a/lib/simfile.dart b/lib/utils/simfile.dart
index 764e3aa..71613a9 100644
--- a/lib/simfile.dart
+++ b/lib/utils/simfile.dart
@@ -1,6 +1,7 @@
-import 'dart:ffi';
import 'dart:io';
+import 'package:audioplayers/audioplayers.dart';
+
enum Difficulty { Beginner, Easy, Medium, Hard, Challenge, Edit }
// These are the standard note values:
@@ -37,12 +38,13 @@ class Chart {
}
class Simfile {
- String directoryPath;
- String? simfilePath;
+ String? directoryPath;
+ String simfilePath;
String? audioPath;
String? bannerPath;
String? lines;
+ Duration? duration;
// tags of simfile
Map<String, String> tags = {};
@@ -51,8 +53,9 @@ class Simfile {
Map<double, double> bpms = {};
double offset = 0;
- Simfile(this.directoryPath);
+ Simfile(this.simfilePath);
+ /// parses a chart tag with metadata [keys] and note data [value]
void _parseChart({required List<String> keys, required String value}) {
Chart chart = Chart();
chart.chartType = keys[1];
@@ -61,6 +64,7 @@ class Simfile {
chart.numericalMeter = int.parse(keys[4]);
chart.radarValues = keys[5];
+ // find simplest chart
if (chartSimplest == null ||
(chart.difficulty!.index <= chartSimplest!.difficulty!.index &&
chart.numericalMeter! <= chartSimplest!.numericalMeter!)) {
@@ -76,8 +80,10 @@ class Simfile {
measures.add(measure);
}
+ // for now only use the first bpm value
double bpm = bpms.entries.first.value;
+ // calculate timing for all notes based on offset, bpm and measure
for (final (measureIndex, measure) in measures.indexed) {
for (final (noteIndex, noteData) in measure.indexed) {
double beat = measureIndex * 4.0 +
@@ -93,10 +99,13 @@ class Simfile {
}
}
+ /// parse a tag based on a regex match [fieldData] and parsing the value based
+ /// on the key
void _parseTag(RegExpMatch fieldData) {
List<String> keys =
fieldData[1]!.split(':').map((key) => key.trim()).toList();
String value = fieldData[2]!;
+
if (keys[0] == "BPMS") {
for (final pairRaw in value.split(',')) {
List<String> pair = pairRaw.split('=');
@@ -120,26 +129,47 @@ class Simfile {
_parseChart(keys: keys, value: value);
}
- void load() {
- simfilePath = Directory(directoryPath)
- .listSync()
- .firstWhere((entity) => entity.path.endsWith('.sm'),
- orElse: () => File('')).path;
-
- audioPath = Directory(directoryPath)
- .listSync()
- .firstWhere((entity) => entity.path.endsWith('.ogg'),
- orElse: () => File(''))
- .path;
-
- lines = File(simfilePath!).readAsStringSync();
-
+ /// load the simfile
+ Future<bool> load() async {
+ directoryPath = File(simfilePath).parent.path;
+ lines = File(simfilePath).readAsStringSync();
+
+ // remove comments
RegExp commentsRegExp = RegExp(r'//.*$');
lines = lines?.replaceAll(commentsRegExp, '');
+ // find all tags
RegExp fieldDataRegExp = RegExp(r'#([^;]+):([^;]*);');
+ // parse all tags
for (final fieldData in fieldDataRegExp.allMatches(lines!)) {
- _parseTag(fieldData);
+ try {
+ _parseTag(fieldData);
+ } catch (err) {
+ return false;
+ }
}
+
+ // searching for audio and banned in the directory is more robust than using
+ // values from metadata as they are wrong more often
+ for (FileSystemEntity entity in Directory(directoryPath!).listSync()) {
+ if (entity.path.endsWith('.ogg')) {
+ audioPath = entity.path;
+ }
+ if (entity.path.endsWith('anner.png')) {
+ bannerPath = entity.path;
+ }
+ }
+
+ // dont use this simfile of files are missing
+ if (audioPath == null) return false;
+ if (bannerPath == null) return false;
+
+ // get duration from audio
+ AudioPlayer audioplayer = AudioPlayer();
+ await audioplayer.setSource(DeviceFileSource(audioPath!));
+ duration = await audioplayer.getDuration();
+ audioplayer.dispose();
+
+ return true;
}
}
diff --git a/lib/widgets/arrow.dart b/lib/widgets/arrow.dart
new file mode 100644
index 0000000..1ad0ec4
--- /dev/null
+++ b/lib/widgets/arrow.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+
+class Arrow extends StatelessWidget {
+ final double position;
+ final ArrowDirection direction;
+
+ const Arrow({super.key, required this.position, required this.direction});
+
+ @override
+ Widget build(BuildContext context) {
+ return Positioned(
+ left: MediaQuery.of(context).size.width / 2 - 50, // Center the arrow
+ bottom: position + 50,
+ child: Icon(size: 100, color: Colors.redAccent.shade400, direction.icon),
+ );
+ }
+}
diff --git a/lib/widgets/arrows.dart b/lib/widgets/arrows.dart
new file mode 100644
index 0000000..162f0f3
--- /dev/null
+++ b/lib/widgets/arrows.dart
@@ -0,0 +1,23 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/widgets/arrow.dart';
+
+class Arrows extends StatelessWidget {
+ final List<Note> notes;
+
+ const Arrows({super.key, required this.notes});
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: notes.map((note) {
+ double position =
+ note.position * 10000; // * 20 * MediaQuery.of(context).size.height;
+
+ return Arrow(
+ position: position,
+ direction: note.direction,
+ );
+ }).toList());
+ }
+}
diff --git a/lib/widgets/connection_status_button.dart b/lib/widgets/connection_status_button.dart
new file mode 100644
index 0000000..e0c4cb4
--- /dev/null
+++ b/lib/widgets/connection_status_button.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/widgets/esense_connect_dialog.dart';
+
+class ConnectionStatusButton extends StatelessWidget {
+ final String deviceStatus;
+ const ConnectionStatusButton(
+ this.deviceStatus, {
+ super.key,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return FilledButton.icon(
+ style: ButtonStyle(
+ backgroundColor: WidgetStateProperty.all(
+ ESenseInput.instance.connected ? Colors.green : Colors.grey),
+ padding:
+ WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 8.0))),
+ onPressed: () => showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return ESenseConnectDialog(
+ deviceStatus: ESenseInput.instance.deviceStatus,
+ connect: (String name) {
+ ESenseInput.instance.connectToESense(name);
+ },
+ disconnect: () {
+ ESenseInput.instance.eSenseManager.disconnect();
+ },
+ );
+ },
+ ),
+ label: Text(deviceStatus),
+ iconAlignment: IconAlignment.end,
+ icon: Icon(ESenseInput.instance.connected
+ ? Icons.bluetooth_connected
+ : Icons.bluetooth));
+ }
+}
diff --git a/lib/widgets/esense_connect_dialog.dart b/lib/widgets/esense_connect_dialog.dart
new file mode 100644
index 0000000..2853ffe
--- /dev/null
+++ b/lib/widgets/esense_connect_dialog.dart
@@ -0,0 +1,64 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+
+class ESenseConnectDialog extends StatefulWidget {
+ final void Function(String) connect;
+ final VoidCallback disconnect;
+ final ValueNotifier<String> deviceStatus;
+
+ const ESenseConnectDialog(
+ {super.key,
+ required this.deviceStatus,
+ required this.connect,
+ required this.disconnect});
+
+ @override
+ State<ESenseConnectDialog> createState() => _ESenseConnectDialogState();
+}
+
+class _ESenseConnectDialogState extends State<ESenseConnectDialog> {
+ String _eSenseDeviceName = '';
+
+ @override
+ Widget build(BuildContext context) {
+ // rerender whenever the deviceStatus changes
+ return ValueListenableBuilder(
+ valueListenable: widget.deviceStatus,
+ builder: (BuildContext context, String deviceStatus, Widget? child) {
+ return AlertDialog(
+ title: const Text('Connect to ESense'),
+ content: Column(mainAxisSize: MainAxisSize.min, children: [
+ TextField(
+ onChanged: (input) {
+ setState(() {
+ _eSenseDeviceName = input;
+ });
+ },
+ decoration: InputDecoration(
+ border: OutlineInputBorder(),
+ hintText: 'eSense-xxxx',
+ labelText: 'Device name',
+ ),
+ ),
+ // Text(eSenseDeviceName),
+ Text(deviceStatus)
+ ]),
+ actions: <Widget>[
+ TextButton(
+ onPressed: () => Navigator.pop(context, 'Cancel'),
+ child: const Text('Close'),
+ ),
+ ESenseInput.instance.connected
+ ? TextButton(
+ onPressed: () => widget.disconnect(),
+ child: const Text('Disconnect'),
+ )
+ : TextButton(
+ onPressed: () => widget.connect(_eSenseDeviceName),
+ child: const Text('Connect'),
+ ),
+ ],
+ );
+ });
+ }
+}
diff --git a/lib/widgets/esense_not_connected_dialog.dart b/lib/widgets/esense_not_connected_dialog.dart
new file mode 100644
index 0000000..32d1d6b
--- /dev/null
+++ b/lib/widgets/esense_not_connected_dialog.dart
@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+
+class ESenseNotConnectedDialog extends StatelessWidget {
+ const ESenseNotConnectedDialog(
+ {super.key, required this.onCancel, required this.onContinue});
+
+ final VoidCallback onCancel;
+ final VoidCallback onContinue;
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text("ESense not connected"),
+ content: const Text(
+ "You will only be able to play with the arrow keys of an external keyboard. "),
+ actions: <Widget>[
+ TextButton(
+ onPressed: () {
+ Navigator.pop(context, 'Cancel');
+ onCancel();
+ },
+ child: const Text('Connect to ESense'),
+ ),
+ TextButton(
+ onPressed: () {
+ Navigator.pop(context, 'Cancel');
+ onContinue();
+ },
+ child: const Text('Continue anyway'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/widgets/level_info_chip.dart b/lib/widgets/level_info_chip.dart
new file mode 100644
index 0000000..8e4146c
--- /dev/null
+++ b/lib/widgets/level_info_chip.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+
+class LevelInfoChip extends StatelessWidget {
+ final String label;
+ final IconData icon;
+
+ const LevelInfoChip({super.key, required this.label, required this.icon});
+
+ @override
+ Widget build(BuildContext context) {
+ return OutlinedButton(
+ style: ButtonStyle(
+ shape: WidgetStateProperty.all(RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(Radius.circular(5)))),
+ minimumSize: WidgetStateProperty.all(Size(10, 10)),
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ padding: WidgetStateProperty.all(
+ EdgeInsets.symmetric(vertical: 4.0, horizontal: 5.0))
+ ),
+ onPressed: () {},
+ child: Row(children: [
+ Icon(
+ icon,
+ size: 16,
+ ),
+ SizedBox(width: 4),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight:
+ FontWeight.w200), // Adjust font size for smaller appearance
+ ),
+ ]),
+ );
+ }
+}
diff --git a/lib/widgets/level_list_entry.dart b/lib/widgets/level_list_entry.dart
new file mode 100644
index 0000000..ad7766d
--- /dev/null
+++ b/lib/widgets/level_list_entry.dart
@@ -0,0 +1,95 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/screens/level.dart';
+import 'package:sense_the_rhythm/widgets/esense_connect_dialog.dart';
+import 'package:sense_the_rhythm/widgets/esense_not_connected_dialog.dart';
+import 'package:sense_the_rhythm/widgets/level_info_chip.dart';
+
+class LevelListEntry extends StatelessWidget {
+ const LevelListEntry({
+ super.key,
+ required this.simfile,
+ });
+
+ final Simfile simfile;
+
+ /// navigates to level screen
+ void _navigateToLevel(BuildContext context) {
+ Navigator.push(context,
+ MaterialPageRoute(builder: (BuildContext context) => Level(simfile)));
+ }
+
+ /// opens ESenseConnectDialog
+ void _openESenseConnectDialog(context) {
+ showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return ESenseConnectDialog(
+ deviceStatus: ESenseInput.instance.deviceStatus,
+ connect: (String name) {
+ ESenseInput.instance.connectToESense(name);
+ },
+ disconnect: () {
+ ESenseInput.instance.eSenseManager.disconnect();
+ },
+ );
+ },
+ );
+ }
+
+ /// when clocked on the level, warn if not connected to ESense
+ void _tapHandler(BuildContext context) {
+ if (ESenseInput.instance.connected) {
+ _navigateToLevel(context);
+ } else {
+ showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return ESenseNotConnectedDialog(
+ onCancel: () {
+ _openESenseConnectDialog(context);
+ },
+ onContinue: () {
+ _navigateToLevel(context);
+ },
+ );
+ },
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ leading: Image.file(File(simfile.bannerPath!)),
+ trailing: Icon(Icons.play_arrow),
+ title: Text(
+ simfile.tags["TITLE"]!,
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ subtitle: Padding(
+ padding: const EdgeInsets.only(bottom: 2),
+ child: Row(
+ spacing: 2,
+ children: [
+ LevelInfoChip(
+ label:
+ '${simfile.duration!.inMinutes}:${simfile.duration!.inSeconds.remainder(60).toString().padLeft(2, "0")}',
+ icon: Icons.timer_outlined,
+ ),
+ LevelInfoChip(
+ label: '${simfile.bpms.entries.first.value.toInt()} BPM',
+ icon: Icons.graphic_eq,
+ ),
+ ],
+ ),
+ ),
+ onTap: () {
+ _tapHandler(context);
+ },
+ );
+ }
+}