summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/level.dart169
-rw-r--r--lib/level_selection.dart113
-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.dart63
-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.dart175
-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
18 files changed, 1220 insertions, 283 deletions
diff --git a/lib/level.dart b/lib/level.dart
deleted file mode 100644
index 1391f8a..0000000
--- a/lib/level.dart
+++ /dev/null
@@ -1,169 +0,0 @@
-import 'dart:async';
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-import 'package:audioplayers/audioplayers.dart';
-
-class Level extends StatefulWidget {
- const Level({super.key, required this.stepmaniaFolderPath});
- final String stepmaniaFolderPath;
-
- @override
- State<Level> createState() => _LevelState();
-}
-
-class _LevelState extends State<Level> {
- final player = AudioPlayer();
- bool _isPlaying = true;
- Duration? _duration;
- Duration? _position;
-
- StreamSubscription? _durationSubscription;
- StreamSubscription? _positionSubscription;
-
- @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),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- player.onDurationChanged.listen((Duration d) {
- // print('Max duration: $d');
- setState(() => _duration = d);
- });
-
- player.onPositionChanged.listen((Duration p) {
- // print('Current position: $p');
- setState(() => _position = p);
- });
-
- String audioPath = Directory(widget.stepmaniaFolderPath)
- .listSync()
- .firstWhere((entity) => entity.path.endsWith('.ogg'),
- orElse: () => File(''))
- .path;
- player.play(DeviceFileSource(audioPath));
- return 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: [
- Arrow(
- position: -100.0,
- ),
- Arrow(
- position: 00.0,
- ),
- Arrow(
- position: 100.0,
- ),
- Arrow(
- position: 200.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,
- // color: Colors.blue,
- border: Border.all(color: Colors.black, width: 10)),
- ),
- ),
- ]));
- }
-
- @override
- void dispose() {
- _durationSubscription?.cancel();
- _positionSubscription?.cancel();
- player.dispose();
- super.dispose();
- }
-}
-
-class Arrow extends StatelessWidget {
- final double position;
-
- const Arrow({super.key, required this.position});
-
- @override
- Widget build(BuildContext context) {
- return Positioned(
- left: MediaQuery.of(context).size.width / 2 - 25, // Center the arrow
- top: position,
- child: Icon(size: 100, Icons.arrow_forward),
- );
- }
-}
diff --git a/lib/level_selection.dart b/lib/level_selection.dart
deleted file mode 100644
index e2cdcbe..0000000
--- a/lib/level_selection.dart
+++ /dev/null
@@ -1,113 +0,0 @@
-import 'dart:io';
-
-import 'package:file_picker/file_picker.dart';
-import 'package:flutter/material.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 = [];
-
- @override
- void initState() {
- super.initState();
- loadFolderPath();
- }
-
- Future<void> loadFolderPath() async {
- SharedPreferences prefs = await SharedPreferences.getInstance();
- final String? stepmaniaCoursesPathSetting =
- prefs.getString('stepmania_courses');
-
- if (stepmaniaCoursesPathSetting == null) return;
- setState(() {
- stepmaniaCoursesPath = stepmaniaCoursesPathSetting;
- });
- setState(() async {
- stepmaniaCoursesFolders =
- await listFilesAndFolders(stepmaniaCoursesPathSetting);
- });
- }
-
- 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')),
- 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/screens/game_over.dart b/lib/screens/game_over.dart
new file mode 100644
index 0000000..76ea2fb
--- /dev/null
+++ b/lib/screens/game_over.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.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});
+
+ final Simfile simfile;
+ final List<Note> notes;
+
+ @override
+ Widget build(BuildContext context) {
+ int hits = notes.where((note) => note.wasHit == true).length;
+ int misses = notes.where((note) => note.wasHit == false).length;
+ int total = notes.length;
+ int percent = (hits.toDouble() / total.toDouble() * 100).toInt();
+
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ onPressed: () => Navigator.pop(context),
+ icon: Icon(Icons.arrow_back)),
+ title: Text('Game Stats'),
+ ),
+ body: Center(
+ child: Column(
+ children: [
+ Text(' $percent%',
+ style: TextStyle(
+ fontSize: 60,
+ fontWeight: FontWeight.bold,
+ color: Colors.orange)),
+ DataTable(columns: [
+ DataColumn(label: Container()),
+ DataColumn(label: Container()),
+ ], rows: [
+ DataRow(cells: [
+ DataCell(Text('Hits')),
+ DataCell(Text(hits.toString())),
+ ]),
+ DataRow(cells: [
+ DataCell(Text('Misses')),
+ DataCell(Text(misses.toString())),
+ ]),
+ DataRow(cells: [
+ DataCell(Text('Total')),
+ DataCell(Text(total.toString())),
+ ]),
+ ]),
+ TextButton(
+ onPressed: () {
+ 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/utils/simfile.dart b/lib/utils/simfile.dart
new file mode 100644
index 0000000..71613a9
--- /dev/null
+++ b/lib/utils/simfile.dart
@@ -0,0 +1,175 @@
+import 'dart:io';
+
+import 'package:audioplayers/audioplayers.dart';
+
+enum Difficulty { Beginner, Easy, Medium, Hard, Challenge, Edit }
+
+// These are the standard note values:
+//
+// 0 – No note
+// 1 – Normal note
+// 2 – Hold head
+// 3 – Hold/Roll tail
+// 4 – Roll head
+// M – Mine (or other negative note)
+//
+// Later versions of StepMania accept other note values which may not work in older versions:
+//
+// K – Automatic keysound
+// L – Lift note
+// F – Fake note
+
+RegExp noteTypes = RegExp(r'^([012345MKLF]+)\s*([,;])?');
+
+class Chart {
+ String? chartType;
+ // Description/author
+ String? author;
+ // Difficulty (one of Beginner, Easy, Medium, Hard, Challenge, Edit)
+ Difficulty? difficulty;
+ // Numerical meter
+ int? numericalMeter;
+ // Groove radar values, generated by the program
+ String? radarValues;
+
+ List<List<String>>? measures;
+
+ Map<double, String> beats = {};
+}
+
+class Simfile {
+ String? directoryPath;
+ String simfilePath;
+ String? audioPath;
+ String? bannerPath;
+ String? lines;
+
+ Duration? duration;
+ // tags of simfile
+ Map<String, String> tags = {};
+
+ Chart? chartSimplest;
+
+ Map<double, double> bpms = {};
+ double offset = 0;
+
+ 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];
+ chart.author = keys[2];
+ chart.difficulty = Difficulty.values.byName(keys[3]);
+ 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!)) {
+ List<List<String>> measures = [];
+ for (final measureRaw in value.split(',')) {
+ List<String> measure = [];
+ for (final noteRaw in measureRaw.split('\n')) {
+ String note = noteRaw.trim();
+ if (noteTypes.hasMatch(note)) {
+ measure.add(note);
+ }
+ }
+ 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 +
+ (noteIndex.toDouble() / measure.length) * 4.0;
+ double minutesPerBeat = 1.0 / bpm;
+ double offsetMinutes = offset / 60.0;
+ chart.beats[beat * minutesPerBeat + offsetMinutes] = noteData;
+ }
+ }
+
+ chart.measures = measures;
+ chartSimplest = chart;
+ }
+ }
+
+ /// 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('=');
+ if (pair.length != 2) {
+ continue;
+ }
+ double time = double.parse(pair[0]);
+ double bpm = double.parse(pair[1]);
+ bpms[time] = bpm;
+ }
+ }
+
+ if (keys[0] == "OFFSET") {
+ offset = double.parse(value);
+ }
+
+ if (keys[0] != "NOTES") {
+ tags[keys[0]] = value;
+ return;
+ }
+ _parseChart(keys: keys, value: value);
+ }
+
+ /// 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!)) {
+ 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);
+ },
+ );
+ }
+}