diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/arrows.dart | 57 | ||||
| -rw-r--r-- | lib/level.dart | 243 | ||||
| -rw-r--r-- | lib/level_selection.dart | 113 | ||||
| -rw-r--r-- | lib/main.dart | 2 | ||||
| -rw-r--r-- | lib/models/arrow_direction.dart | 12 | ||||
| -rw-r--r-- | lib/models/input_direction.dart | 14 | ||||
| -rw-r--r-- | lib/models/note.dart | 10 | ||||
| -rw-r--r-- | lib/screens/game_over.dart | 63 | ||||
| -rw-r--r-- | lib/screens/level.dart | 285 | ||||
| -rw-r--r-- | lib/screens/level_selection.dart | 161 | ||||
| -rw-r--r-- | lib/utils/esense_input.dart | 188 | ||||
| -rw-r--r-- | lib/utils/simfile.dart (renamed from lib/simfile.dart) | 58 | ||||
| -rw-r--r-- | lib/widgets/arrow.dart | 18 | ||||
| -rw-r--r-- | lib/widgets/arrows.dart | 23 | ||||
| -rw-r--r-- | lib/widgets/connection_status_button.dart | 40 | ||||
| -rw-r--r-- | lib/widgets/esense_connect_dialog.dart | 64 | ||||
| -rw-r--r-- | lib/widgets/esense_not_connected_dialog.dart | 34 | ||||
| -rw-r--r-- | lib/widgets/level_info_chip.dart | 37 | ||||
| -rw-r--r-- | lib/widgets/level_list_entry.dart | 95 | 
19 files changed, 1096 insertions, 421 deletions
| diff --git a/lib/arrows.dart b/lib/arrows.dart deleted file mode 100644 index b4779f7..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 = false; - -  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/level.dart b/lib/level.dart deleted file mode 100644 index a6d4967..0000000 --- a/lib/level.dart +++ /dev/null @@ -1,243 +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/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.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 && 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('This is a toast message'), -                duration: Duration(seconds: 2), -              ), -            ); -          } -        } -      } -    }); - -    String simfilePath = Directory(widget.stepmaniaFolderPath) -        .listSync() -        .firstWhere((entity) => entity.path.endsWith('.sm'), -            orElse: () => File('')) -        .path; - -    String audioPath = Directory(widget.stepmaniaFolderPath) -        .listSync() -        .firstWhere((entity) => entity.path.endsWith('.ogg'), -            orElse: () => File('')) -        .path; - -    simfile = Simfile(simfilePath); -    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])); -    }); - -    print(audioPath); - -    player.play(DeviceFileSource(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 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/simfile.dart b/lib/utils/simfile.dart index 102c989..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,9 +38,13 @@ class Chart {  }  class Simfile { -  String path; +  String? directoryPath; +  String simfilePath; +  String? audioPath; +  String? bannerPath;    String? lines; +  Duration? duration;    // tags of simfile    Map<String, String> tags = {}; @@ -48,8 +53,9 @@ class Simfile {    Map<double, double> bpms = {};    double offset = 0; -  Simfile(this.path); +  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]; @@ -58,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!)) { @@ -73,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 + @@ -90,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('='); @@ -117,15 +129,47 @@ class Simfile {      _parseChart(keys: keys, value: value);    } -  void load() { -    lines = File(path).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); +      }, +    ); +  } +} | 
