From d0f72e3f449e9bc9872c79c12c7aea96b2e42ca9 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sat, 28 Mar 2026 11:54:34 +0100 Subject: [PATCH 01/65] feat: add package flutter_gamepads with a widget that emits intents based on gamepad input Users can move focus, activate widgets and emit dismiss intents which closes alert dialogs for example. Not all flutter widgets fully can be operated with the global intents. For example the Slider widgets uses private intents with additional support for keyboard shortcuts. --- README.md | 6 + packages/flutter_gamepads/.gitignore | 31 ++ packages/flutter_gamepads/.metadata | 10 + packages/flutter_gamepads/CHANGELOG.md | 3 + packages/flutter_gamepads/LICENSE | 1 + packages/flutter_gamepads/README.md | 3 + .../flutter_gamepads/analysis_options.yaml | 1 + packages/flutter_gamepads/example/.gitignore | 53 +++ packages/flutter_gamepads/example/.metadata | 30 ++ packages/flutter_gamepads/example/README.md | 3 + .../example/analysis_options.yaml | 1 + .../flutter_gamepads/example/lib/main.dart | 306 ++++++++++++++++++ .../flutter_gamepads/example/pubspec.yaml | 23 ++ .../lib/flutter_gamepads.dart | 1 + .../flutter_gamepads/lib/src/_old_code.txt | 64 ++++ .../lib/src/gamepad_activator.dart | 103 ++++++ .../lib/src/gamepad_control.dart | 209 ++++++++++++ packages/flutter_gamepads/pubspec.yaml | 23 ++ .../test/flutter_gamepads_test.dart | 144 +++++++++ pubspec.yaml | 2 + 20 files changed, 1017 insertions(+) create mode 100644 packages/flutter_gamepads/.gitignore create mode 100644 packages/flutter_gamepads/.metadata create mode 100644 packages/flutter_gamepads/CHANGELOG.md create mode 100644 packages/flutter_gamepads/LICENSE create mode 100644 packages/flutter_gamepads/README.md create mode 100644 packages/flutter_gamepads/analysis_options.yaml create mode 100644 packages/flutter_gamepads/example/.gitignore create mode 100644 packages/flutter_gamepads/example/.metadata create mode 100644 packages/flutter_gamepads/example/README.md create mode 100644 packages/flutter_gamepads/example/analysis_options.yaml create mode 100644 packages/flutter_gamepads/example/lib/main.dart create mode 100644 packages/flutter_gamepads/example/pubspec.yaml create mode 100644 packages/flutter_gamepads/lib/flutter_gamepads.dart create mode 100644 packages/flutter_gamepads/lib/src/_old_code.txt create mode 100644 packages/flutter_gamepads/lib/src/gamepad_activator.dart create mode 100644 packages/flutter_gamepads/lib/src/gamepad_control.dart create mode 100644 packages/flutter_gamepads/pubspec.yaml create mode 100644 packages/flutter_gamepads/test/flutter_gamepads_test.dart diff --git a/README.md b/README.md index d711d489f..7cdbe2f89 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,12 @@ redistributable. This is because gamepads uses GameInput API v0 which is statically linked. +## Bridge packages + +* [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - Provides a GamepadCallbacks component mixin for your Flame games +* [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users to navigating a Flutter widgets tree using a gamepad. + + ## Support The simplest way to show us your support is by giving the project a star! :star: diff --git a/packages/flutter_gamepads/.gitignore b/packages/flutter_gamepads/.gitignore new file mode 100644 index 000000000..dd5eb9895 --- /dev/null +++ b/packages/flutter_gamepads/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/flutter_gamepads/.metadata b/packages/flutter_gamepads/.metadata new file mode 100644 index 000000000..c196d6b2d --- /dev/null +++ b/packages/flutter_gamepads/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "[user-branch]" + +project_type: package diff --git a/packages/flutter_gamepads/CHANGELOG.md b/packages/flutter_gamepads/CHANGELOG.md new file mode 100644 index 000000000..c52abc3de --- /dev/null +++ b/packages/flutter_gamepads/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + + - Initial release diff --git a/packages/flutter_gamepads/LICENSE b/packages/flutter_gamepads/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/packages/flutter_gamepads/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md new file mode 100644 index 000000000..6f351b2fa --- /dev/null +++ b/packages/flutter_gamepads/README.md @@ -0,0 +1,3 @@ +# flutter_gamepads + +A Flutter plugin to handle gamepad input across multiple platforms. \ No newline at end of file diff --git a/packages/flutter_gamepads/analysis_options.yaml b/packages/flutter_gamepads/analysis_options.yaml new file mode 100644 index 000000000..ba5631f3b --- /dev/null +++ b/packages/flutter_gamepads/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/example/.gitignore b/packages/flutter_gamepads/example/.gitignore new file mode 100644 index 000000000..092376903 --- /dev/null +++ b/packages/flutter_gamepads/example/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + + +/test +/macos +/android +/ios +/web +/windows +/linux \ No newline at end of file diff --git a/packages/flutter_gamepads/example/.metadata b/packages/flutter_gamepads/example/.metadata new file mode 100644 index 000000000..f0bb85c04 --- /dev/null +++ b/packages/flutter_gamepads/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: windows + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flutter_gamepads/example/README.md b/packages/flutter_gamepads/example/README.md new file mode 100644 index 000000000..b1411d4f5 --- /dev/null +++ b/packages/flutter_gamepads/example/README.md @@ -0,0 +1,3 @@ +# gamepads_example + +A simple example project showcasing the gamepads plugin. diff --git a/packages/flutter_gamepads/example/analysis_options.yaml b/packages/flutter_gamepads/example/analysis_options.yaml new file mode 100644 index 000000000..ba5631f3b --- /dev/null +++ b/packages/flutter_gamepads/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart new file mode 100644 index 000000000..778ca6085 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; + +void main() { + runApp(const MyApp()); +} + +/// This app has been AI generated which took a few attempts to iron out some +/// weirdness, but still can have some weird stuff in it. +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + final baseTheme = ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ); + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter Gamepads Demo', + theme: baseTheme.copyWith( + focusColor: Colors.orange, + inputDecorationTheme: InputDecorationTheme( + border: const OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Colors.orange, + width: 3, + ), + ), + ), + navigationBarTheme: const NavigationBarThemeData( + indicatorColor: Color(0x332196F3), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + side: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return const BorderSide( + color: Colors.orange, + width: 3, + ); + } + return BorderSide.none; + }), + ), + ), + cardTheme: CardThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + initialRoute: '/', + routes: { + '/': (context) => const HomePage(), + '/settings': (context) => const SettingsPage(), + }, + ); + } +} + +class AppShell extends StatelessWidget { + const AppShell({ + required this.title, + required this.index, + required this.child, + super.key, + }); + + final String title; + final int index; + final Widget child; + + void _goToPage(BuildContext context, int newIndex) { + if (newIndex == 0) { + Navigator.pushReplacementNamed(context, '/'); + } else { + Navigator.pushReplacementNamed(context, '/settings'); + } + } + + @override + Widget build(BuildContext context) { + return GamepadControl( + child: Scaffold( + appBar: AppBar( + title: Text(title), + centerTitle: true, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (value) => _goToPage(context, value), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.tune_outlined), + selectedIcon: Icon(Icons.tune), + label: 'Settings', + ), + ], + ), + ), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return AppShell( + title: 'Flutter Gamepads sample app', + index: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Flutter Gamepads sample app', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + const Text( + 'A short sample app for flutter gamepad testing.', + ), + ], + ), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('List of gamepad buttons'), + content: SizedBox( + height: 200, + width: 200, + child: ListView( + children: GamepadButton.values + .map((b) => Text(b.name)) + .toList(), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + }, + child: const Text('Show dialog'), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Primary action triggered')), + ); + }, + child: const Text('Show snackbar'), + ), + ], + ), + ); + } +} + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + double volume = 50; + bool vibrationEnabled = true; + String selectedMode = 'Adventure'; + final TextEditingController nameController = TextEditingController( + text: 'Player One', + ); + + final List gameModes = ['Adventure', 'Arcade', 'Challenge']; + + @override + void dispose() { + nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppShell( + title: 'Settings', + index: 1, + child: ListView( + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Player name', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Volume: ${volume.round()}'), + Slider( + value: volume, + max: 100, + divisions: 10, + label: volume.round().toString(), + onChanged: (value) { + setState(() { + volume = value; + }); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: selectedMode, + decoration: const InputDecoration(labelText: 'Game mode'), + items: gameModes + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(mode), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + selectedMode = value; + }); + }, + ), + const SizedBox(height: 8), + SwitchListTile( + title: const Text('Enable vibration'), + value: vibrationEnabled, + onChanged: (value) { + setState(() { + vibrationEnabled = value; + }); + }, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Saved ${nameController.text} / $selectedMode / ${volume.round()}', + ), + ), + ); + }, + child: const Text('Save settings'), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/pubspec.yaml b/packages/flutter_gamepads/example/pubspec.yaml new file mode 100644 index 000000000..f7f4854ef --- /dev/null +++ b/packages/flutter_gamepads/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: flutter_gamepads_example +description: A simple example project showcasing the flutter_gamepads plugin. +resolution: workspace +publish_to: 'none' + +version: 0.1.0 + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_gamepads: ^0.1.0 + gamepads: ^0.1.10 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/flutter_gamepads/lib/flutter_gamepads.dart b/packages/flutter_gamepads/lib/flutter_gamepads.dart new file mode 100644 index 000000000..110fd3c6d --- /dev/null +++ b/packages/flutter_gamepads/lib/flutter_gamepads.dart @@ -0,0 +1 @@ +export 'src/gamepad_control.dart'; diff --git a/packages/flutter_gamepads/lib/src/_old_code.txt b/packages/flutter_gamepads/lib/src/_old_code.txt new file mode 100644 index 000000000..3eed39098 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/_old_code.txt @@ -0,0 +1,64 @@ + final Set activateButtons; + final Set dismissButtons; + final Set prevButtons; + final Set nextButtons; + + /// Set of buttons that performs ActivateIntent + this.activateButtons = const {GamepadButton.a}, + + /// Set of buttons that calls the [onBack] function + this.dismissButtons = const {GamepadButton.b}, + + /// Set of buttons that moves focus to previous focus node + this.prevButtons = const {GamepadButton.dpadUp, GamepadButton.dpadLeft}, + + /// Set of buttons that moves focus to next focus node + this.nextButtons = const {GamepadButton.dpadDown, GamepadButton.dpadRight}, + + + void _onGamepadEvent(NormalizedGamepadEvent event) { + if (widget.ignoreEvents == true) { + return; + } + final buttonPressed = event.button != null && event.value != 0; + if (!buttonPressed) { + return; + } + final navBack = widget.dismissButtons.contains(event.button); + final navPrev = widget.prevButtons.contains(event.button); + final navNext = widget.nextButtons.contains(event.button); + final navActivate = widget.activateButtons.contains(event.button); + + // Same lookup as ShortcutManager.handleKeypress + final primaryFocus = WidgetsBinding.instance.focusManager.primaryFocus; + final focusedContext = primaryFocus?.context; + + // TODO: detect if focus actually is in the parent FocusScope and reject prev/next/back if it is not + // TODO: maybe wrap FocusControl in a FocusScope by default? (eg. FocusControl => FocusScope => FocusControlInternal, and move most logic to ..Internal) + if (navPrev) { + Actions.maybeInvoke(focusedContext ?? context, PreviousFocusIntent()); + //FocusScope.of(focusedContext ?? context).previousFocus(); + } + if (navNext) { + Actions.maybeInvoke(focusedContext ?? context, NextFocusIntent()); + //FocusScope.of(focusedContext ?? context).nextFocus(); + } + if (navBack) { + bool emitEvent = true; + if (widget.onBeforeIntent != null) { + emitEvent = widget.onBeforeIntent!(DismissIntent()); + } + if (emitEvent) { + Actions.maybeInvoke(focusedContext ?? context, DismissIntent()); + } + } + if (navActivate && focusedContext != null) { + bool emitEvent = true; + if (widget.onBeforeIntent != null) { + emitEvent = widget.onBeforeIntent!(DismissIntent()); + } + if (emitEvent) { + Actions.maybeInvoke(focusedContext, ActivateIntent()); + } + } + } \ No newline at end of file diff --git a/packages/flutter_gamepads/lib/src/gamepad_activator.dart b/packages/flutter_gamepads/lib/src/gamepad_activator.dart new file mode 100644 index 000000000..7281a5657 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/gamepad_activator.dart @@ -0,0 +1,103 @@ +import 'package:gamepads/gamepads.dart'; + +class GamepadActivator { + const GamepadActivator(); +} + +class GamepadActivatorButton extends GamepadActivator { + final GamepadButton button; + const GamepadActivatorButton({required this.button}); + + /// GamepadActivator for [GamepadButton.a] + const GamepadActivatorButton.a() : button = GamepadButton.a; + + /// GamepadActivator for [GamepadButton.b] + const GamepadActivatorButton.b() : button = GamepadButton.b; + + /// GamepadActivator for [GamepadButton.x] + const GamepadActivatorButton.x() : button = GamepadButton.x; + + /// GamepadActivator for [GamepadButton.y] + const GamepadActivatorButton.y() : button = GamepadButton.y; + + /// GamepadActivator for [GamepadButton.leftBumper] + const GamepadActivatorButton.leftBumper() : button = GamepadButton.leftBumper; + + /// GamepadActivator for [GamepadButton.rightBumper] + const GamepadActivatorButton.rightBumper() + : button = GamepadButton.rightBumper; + + /// GamepadActivator for [GamepadButton.leftTrigger] + const GamepadActivatorButton.leftTrigger() + : button = GamepadButton.leftTrigger; + + /// GamepadActivator for [GamepadButton.rightTrigger] + const GamepadActivatorButton.rightTrigger() + : button = GamepadButton.rightTrigger; + + /// GamepadActivator for [GamepadButton.back] + const GamepadActivatorButton.back() : button = GamepadButton.back; + + /// GamepadActivator for [GamepadButton.start] + const GamepadActivatorButton.start() : button = GamepadButton.start; + + /// GamepadActivator for [GamepadButton.home] + const GamepadActivatorButton.home() : button = GamepadButton.home; + + /// GamepadActivator for [GamepadButton.leftStick] + const GamepadActivatorButton.leftStick() : button = GamepadButton.leftStick; + + /// GamepadActivator for [GamepadButton.rightStick] + const GamepadActivatorButton.rightStick() : button = GamepadButton.rightStick; + + /// GamepadActivator for [GamepadButton.dpadUp] + const GamepadActivatorButton.dpadUp() : button = GamepadButton.dpadUp; + + /// GamepadActivator for [GamepadButton.dpadDown] + const GamepadActivatorButton.dpadDown() : button = GamepadButton.dpadDown; + + /// GamepadActivator for [GamepadButton.dpadLeft] + const GamepadActivatorButton.dpadLeft() : button = GamepadButton.dpadLeft; + + /// GamepadActivator for [GamepadButton.dpadRight] + const GamepadActivatorButton.dpadRight() : button = GamepadButton.dpadRight; +} + +class GamepadActivatorAxis extends GamepadActivator { + final GamepadAxis axis; + final double minThreshold; + const GamepadActivatorAxis({required this.axis, required this.minThreshold}); + + const GamepadActivatorAxis.leftStickUp() + : axis = GamepadAxis.leftStickY, + minThreshold = _threshold; + const GamepadActivatorAxis.leftStickDown() + : axis = GamepadAxis.leftStickY, + minThreshold = -_threshold; + const GamepadActivatorAxis.leftStickLeft() + : axis = GamepadAxis.leftStickX, + minThreshold = -_threshold; + const GamepadActivatorAxis.leftStickRight() + : axis = GamepadAxis.leftStickX, + minThreshold = _threshold; + const GamepadActivatorAxis.rightStickUp() + : axis = GamepadAxis.rightStickY, + minThreshold = _threshold; + const GamepadActivatorAxis.rightStickDown() + : axis = GamepadAxis.rightStickY, + minThreshold = -_threshold; + const GamepadActivatorAxis.rightStickLeft() + : axis = GamepadAxis.rightStickX, + minThreshold = -_threshold; + const GamepadActivatorAxis.rightStickRight() + : axis = GamepadAxis.rightStickX, + minThreshold = _threshold; + const GamepadActivatorAxis.leftTrigger() + : axis = GamepadAxis.leftTrigger, + minThreshold = _threshold; + const GamepadActivatorAxis.rightTrigger() + : axis = GamepadAxis.rightTrigger, + minThreshold = _threshold; +} + +const _threshold = 0.3; diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart new file mode 100644 index 000000000..cf88f94e1 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/src/gamepad_activator.dart'; +import 'package:gamepads/gamepads.dart'; + +/// Wrap your widget tree with this widget to allow users +/// to navigate it using their gamepad. +/// +/// Make sure your theme is setup so that widgets get a clear +/// visual indicator when they are focused. +class GamepadControl extends StatefulWidget { + final Widget child; + final bool ignoreEvents; + final bool Function(Intent)? onBeforeIntent; + + final Map shortcuts; + + const GamepadControl({ + required this.child, + + /// Called just before an Intent is invoked. Return false to block + /// emitting the Intent. + this.onBeforeIntent, + + /// If set to true, the gamepad control is temporarily disabled. It still + /// listen on the gamepad, but just ignores the events. + this.ignoreEvents = false, + + /// Configures the bindings between Gamepad activator (button or axis) + /// and intents to invoke. + /// + /// References of available intents can be found by looking up + /// [WidgetsApp.defaultShortcuts], which contains the default keyboard + /// shortcuts used in apps. + this.shortcuts = const { + GamepadActivatorButton.a(): ActivateIntent(), + GamepadActivatorButton.b(): DismissIntent(), + GamepadActivatorButton.dpadUp(): PreviousFocusIntent(), + GamepadActivatorButton.dpadLeft(): PreviousFocusIntent(), + GamepadActivatorButton.dpadDown(): NextFocusIntent(), + GamepadActivatorButton.dpadRight(): NextFocusIntent(), + GamepadActivatorAxis.rightStickUp(): ScrollIntent( + direction: AxisDirection.up, + ), + GamepadActivatorAxis.rightStickLeft(): ScrollIntent( + direction: AxisDirection.left, + ), + GamepadActivatorAxis.rightStickDown(): ScrollIntent( + direction: AxisDirection.down, + ), + GamepadActivatorAxis.rightStickRight(): ScrollIntent( + direction: AxisDirection.right, + ), + GamepadActivatorAxis.leftStickLeft(): DirectionalFocusIntent( + TraversalDirection.left, + ), + GamepadActivatorAxis.leftStickRight(): DirectionalFocusIntent( + TraversalDirection.right, + ), + GamepadActivatorAxis.leftStickDown(): DirectionalFocusIntent( + TraversalDirection.down, + ), + GamepadActivatorAxis.leftStickUp(): DirectionalFocusIntent( + TraversalDirection.up, + ), + }, + + super.key, + }); + + @override + State createState() => _GamepadControlState(); +} + +class _GamepadControlState extends State { + StreamSubscription? _subscription; + + /// abs() of the lowest minThreshold of any GamepadActivatorAxis + /// used by a shortcut or null if there are no axis shortcuts. + double? _minAxisThreshold; + final Map previousAxisValue = {}; + + @override + void initState() { + super.initState(); + _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); + _minAxisThreshold = _resolveMinAxisThreshold(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant GamepadControl oldWidget) { + super.didUpdateWidget(oldWidget); + _minAxisThreshold = _resolveMinAxisThreshold(); + if (widget.ignoreEvents) { + previousAxisValue.clear(); + } + } + + double? _resolveMinAxisThreshold() { + return widget.shortcuts.keys.fold(null, ( + double? prev, + GamepadActivator activator, + ) { + switch (activator) { + case final GamepadActivatorAxis axisActivator: + final absActivatorMinThreshold = axisActivator.minThreshold.abs(); + if (prev == null) { + return absActivatorMinThreshold; + } + if (absActivatorMinThreshold < prev) { + return absActivatorMinThreshold; + } + case _: + break; + } + return prev; + }); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + void onGamepadEvent(NormalizedGamepadEvent event) { + if (widget.ignoreEvents == true) { + return; + } + final intent = _find(event); + if (intent == null) { + _updatePreviousAxisValues(event); + return; + } + // Same lookup as ShortcutManager.handleKeypress + final primaryFocus = WidgetsBinding.instance.focusManager.primaryFocus; + final focusedContext = primaryFocus?.context; + // Allow previous/next to use parent context when focusContext is null + // to allow user to focus something even when there is no autofocus. + final activateContext = + focusedContext ?? + ((intent is PreviousFocusIntent || intent is NextFocusIntent) + ? context + : null); + if (activateContext != null) { + var emitEvent = true; + if (widget.onBeforeIntent != null) { + emitEvent = widget.onBeforeIntent!(intent); + } + if (emitEvent) { + if (intent is PreviousFocusIntent) { + FocusScope.of(activateContext).previousFocus(); + } else if (intent is NextFocusIntent) { + FocusScope.of(activateContext).nextFocus(); + } else { + Actions.maybeInvoke(activateContext, intent); + } + } + } + + _updatePreviousAxisValues(event); + } + + Intent? _find(NormalizedGamepadEvent event) { + final buttonPressed = event.button != null && event.value != 0; + final axisMaybeActive = + event.axis != null && + _minAxisThreshold != null && + event.value.abs() > _minAxisThreshold!.abs(); + if (!buttonPressed && !axisMaybeActive) { + return null; + } + + for (final entry in widget.shortcuts.entries) { + final activator = entry.key; + switch (activator) { + case final GamepadActivatorButton buttonActivator: + if (buttonActivator.button == event.button) { + return entry.value; + } + case final GamepadActivatorAxis axisActivator: + if (axisActivator.axis == event.axis) { + final activatorSign = axisActivator.minThreshold > 0; + final inputSign = event.value > 0; + if (activatorSign == inputSign && + event.value.abs() > axisActivator.minThreshold.abs() && + (!previousAxisValue.containsKey(axisActivator.axis) || + previousAxisValue[axisActivator.axis]!.abs() <= + axisActivator.minThreshold.abs())) { + return entry.value; + } + } + } + } + return null; + } + + void _updatePreviousAxisValues(NormalizedGamepadEvent event) { + if (event.axis != null) { + previousAxisValue[event.axis!] = event.value; + } + } +} diff --git a/packages/flutter_gamepads/pubspec.yaml b/packages/flutter_gamepads/pubspec.yaml new file mode 100644 index 000000000..1b05c143c --- /dev/null +++ b/packages/flutter_gamepads/pubspec.yaml @@ -0,0 +1,23 @@ +name: flutter_gamepads +resolution: workspace +description: A Flutter plugin to handle gamepad input across multiple platforms. +version: 0.1.10 +homepage: https://github.com/flame-engine/gamepads +repository: https://github.com/flame-engine/gamepads/tree/main/packages/gamepads + +flutter: + +environment: + sdk: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" + +dependencies: + flutter: + sdk: flutter + gamepads: ^0.1.10 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + gamepads_platform_interface: ^0.1.3 diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart new file mode 100644 index 000000000..6ccbc59c3 --- /dev/null +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gamepads/src/gamepad_control.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:gamepads/gamepads.dart'; + +import 'package:gamepads_platform_interface/gamepads_platform_interface.dart'; +import 'package:gamepads_platform_interface/method_channel_gamepads_platform_interface.dart'; + +final platformInterface = + GamepadsPlatformInterface.instance + as MethodChannelGamepadsPlatformInterface; + +enum _UiButton { noButton, first, second } + +void main() { + testWidgets('GamepadControl', (WidgetTester tester) async { + Gamepads.normalizer = GamepadNormalizer.forPlatform( + GamepadPlatform.windows, + ); + var lastButtonPressed = _UiButton.noButton; + var beforeInvokeActivate = false; + var beforeInvokeDismiss = false; + final aFocusNode = FocusNode(); + final bFocusNode = FocusNode(); + final ignoreEvents = ValueNotifier(false); + var emitIntents = true; + + final widget = MaterialApp( + home: ValueListenableBuilder( + valueListenable: ignoreEvents, + builder: (context, value, child) { + return GamepadControl( + onBeforeIntent: (intent) { + if (intent is ActivateIntent) { + beforeInvokeActivate = true; + } else if (intent is DismissIntent) { + beforeInvokeDismiss = true; + } + return emitIntents; + }, + ignoreEvents: ignoreEvents.value, + child: Column( + children: [ + ElevatedButton( + focusNode: aFocusNode, + onPressed: () => lastButtonPressed = _UiButton.first, + child: const Text('First'), + ), + ElevatedButton( + focusNode: bFocusNode, + onPressed: () => lastButtonPressed = _UiButton.second, + child: const Text('Second'), + ), + ], + ), + ); + }, + ), + ); + + await tester.pumpWidget(widget); + await _event('dpadDown'); + await tester.pumpAndSettle(); + + // First UI button has focus + expect(aFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + + // Emit 'a' gamepad button => should press the first UI button + await _event('a'); + await tester.pumpAndSettle(); + expect(aFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.first)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'dpadRight' gamepad button => should focus second UI button + beforeInvokeActivate = false; + lastButtonPressed = _UiButton.noButton; + await _event('dpadRight'); + await tester.pumpAndSettle(); + expect(bFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'a' gamepad button => should press the second UI button + await _event('a'); + await tester.pumpAndSettle(); + expect(bFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.second)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'b' gamepad button => should call onBeforeInvoke with dismiss intent + beforeInvokeActivate = false; + lastButtonPressed = _UiButton.noButton; + await _event('b'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isTrue); + + // allow events, but returning false from onBeforeIntent should block intent + emitIntents = false; + beforeInvokeActivate = false; + beforeInvokeDismiss = false; + lastButtonPressed = _UiButton.noButton; + await _event('a'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // ignore events => should not even call onBeforeIntent or invoke any thing + ignoreEvents.value = true; + emitIntents = true; + await tester.pumpAndSettle(); + beforeInvokeActivate = false; + beforeInvokeDismiss = false; + lastButtonPressed = _UiButton.noButton; + await _event('a'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isFalse); + }); +} + +Future _event(String key) async { + final millis = DateTime.now().millisecondsSinceEpoch; + await platformInterface.platformCallHandler( + MethodCall('onGamepadEvent', { + 'gamepadId': '1', + 'time': millis, + 'type': 'button', + 'key': key, + 'value': 1.0, + }), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 28a578270..8dee05e93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,8 @@ name: gamepads_workspace repository: https://github.com/flame-engine/gamepads workspace: + - packages/flutter_gamepads + - packages/flutter_gamepads/example - packages/gamepads - packages/gamepads/example - packages/gamepads_android From 2daf11aa208b6bf2876c17ab4816e9d76e28f975 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sat, 28 Mar 2026 12:14:09 +0100 Subject: [PATCH 02/65] remove: some old wip code not intented to be included --- .../flutter_gamepads/lib/src/_old_code.txt | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 packages/flutter_gamepads/lib/src/_old_code.txt diff --git a/packages/flutter_gamepads/lib/src/_old_code.txt b/packages/flutter_gamepads/lib/src/_old_code.txt deleted file mode 100644 index 3eed39098..000000000 --- a/packages/flutter_gamepads/lib/src/_old_code.txt +++ /dev/null @@ -1,64 +0,0 @@ - final Set activateButtons; - final Set dismissButtons; - final Set prevButtons; - final Set nextButtons; - - /// Set of buttons that performs ActivateIntent - this.activateButtons = const {GamepadButton.a}, - - /// Set of buttons that calls the [onBack] function - this.dismissButtons = const {GamepadButton.b}, - - /// Set of buttons that moves focus to previous focus node - this.prevButtons = const {GamepadButton.dpadUp, GamepadButton.dpadLeft}, - - /// Set of buttons that moves focus to next focus node - this.nextButtons = const {GamepadButton.dpadDown, GamepadButton.dpadRight}, - - - void _onGamepadEvent(NormalizedGamepadEvent event) { - if (widget.ignoreEvents == true) { - return; - } - final buttonPressed = event.button != null && event.value != 0; - if (!buttonPressed) { - return; - } - final navBack = widget.dismissButtons.contains(event.button); - final navPrev = widget.prevButtons.contains(event.button); - final navNext = widget.nextButtons.contains(event.button); - final navActivate = widget.activateButtons.contains(event.button); - - // Same lookup as ShortcutManager.handleKeypress - final primaryFocus = WidgetsBinding.instance.focusManager.primaryFocus; - final focusedContext = primaryFocus?.context; - - // TODO: detect if focus actually is in the parent FocusScope and reject prev/next/back if it is not - // TODO: maybe wrap FocusControl in a FocusScope by default? (eg. FocusControl => FocusScope => FocusControlInternal, and move most logic to ..Internal) - if (navPrev) { - Actions.maybeInvoke(focusedContext ?? context, PreviousFocusIntent()); - //FocusScope.of(focusedContext ?? context).previousFocus(); - } - if (navNext) { - Actions.maybeInvoke(focusedContext ?? context, NextFocusIntent()); - //FocusScope.of(focusedContext ?? context).nextFocus(); - } - if (navBack) { - bool emitEvent = true; - if (widget.onBeforeIntent != null) { - emitEvent = widget.onBeforeIntent!(DismissIntent()); - } - if (emitEvent) { - Actions.maybeInvoke(focusedContext ?? context, DismissIntent()); - } - } - if (navActivate && focusedContext != null) { - bool emitEvent = true; - if (widget.onBeforeIntent != null) { - emitEvent = widget.onBeforeIntent!(DismissIntent()); - } - if (emitEvent) { - Actions.maybeInvoke(focusedContext, ActivateIntent()); - } - } - } \ No newline at end of file From 92c0349dc15d097283c7775e6743ecc88add35ab Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sat, 28 Mar 2026 12:55:42 +0100 Subject: [PATCH 03/65] docs: Basic usage info in README.md --- packages/flutter_gamepads/README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 6f351b2fa..2277ce669 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -1,3 +1,13 @@ # flutter_gamepads -A Flutter plugin to handle gamepad input across multiple platforms. \ No newline at end of file +A Flutter plugin to handle gamepad input across multiple platforms. + +## GamepadControl + +Wrap your widgets with GamepadControl to allow users to navigate it using their gamepad. + +```dart +GamepadControl( + child: YourWidgets(), +) +``` From ea9fcf735321f65ff8d1e3edd132d62f20124277 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 16:07:51 +0200 Subject: [PATCH 04/65] feat: GamepadInterceptor and updated example Rewritten example to be human-based. More specifically the AI had a Tab based control, but was still building the Bottom tabs on each page. The new example does trigger the issue that each page cannot have a GamepadControl as when the settings page is pushed on the stack the HomePage still remains and there would be duplicate focus change intents. I explored ways to handle that the gamepadControl of the page behind would automatically disable itself, but it was hard to find a reliable solution to that. Instead I changed so there is just one GamepadControl at the top and to be able to locally intercept the input you can use GamepadInterceptor widget. --- packages/flutter_gamepads/README.md | 104 +++++- .../flutter_gamepads/example/lib/main.dart | 298 +----------------- .../example/lib/pages/home_page.dart | 161 ++++++++++ .../example/lib/pages/settings_page.dart | 157 +++++++++ .../example/lib/pages/tab_shell.dart | 18 ++ .../flutter_gamepads/example/lib/theme.dart | 50 +++ .../lib/flutter_gamepads.dart | 2 + .../lib/src/gamepad_control.dart | 60 ++-- .../lib/src/gamepad_interceptor.dart | 25 ++ 9 files changed, 558 insertions(+), 317 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/pages/home_page.dart create mode 100644 packages/flutter_gamepads/example/lib/pages/settings_page.dart create mode 100644 packages/flutter_gamepads/example/lib/pages/tab_shell.dart create mode 100644 packages/flutter_gamepads/example/lib/theme.dart create mode 100644 packages/flutter_gamepads/lib/src/gamepad_interceptor.dart diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 2277ce669..4ae56012d 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -2,12 +2,110 @@ A Flutter plugin to handle gamepad input across multiple platforms. -## GamepadControl -Wrap your widgets with GamepadControl to allow users to navigate it using their gamepad. +## Supported interaction + +Using just `GamepadControl` out-of-the-box allow users to change focus around your app +similar to using the Tab key, but using the D-pad buttons on their gamepad. Several +'simple' widgets like Buttons, DropdownMenu, Switch etc. just works. + +Some more complex interactive widgets like eg. the Slider widget needs some special attention +to support. Another situation is when you have a Scroll view that doesn't receive focus. This +package provides a `GamepadInterceptor` widget that you can use to handle those situations. + + +### Default bindings + +* Activate: A +* Dismiss: B +* Previous focus: D-pad up or D-pad left +* Next focus: D-pad down or D-pad right +* Scroll up: Right stick up +* Scroll down: Right stick down +* Scroll left: Right stick left +* Scroll right: Right stick right + + +## Usage + +### GamepadControl + +Wrap your widgets with GamepadControl to allow users to navigate it using their gamepad. It is +recommended to at any given time only have one GamepadControl in your widget tree. ```dart GamepadControl( - child: YourWidgets(), + child: MaterialApp(), ) ``` + + +### GamepadInterceptor + +If you want to intercept a Gamepad intent locally next to a Widget you can do so with +`GamepadInterceptor`. Its onBeforeIntent is only called if a descendant widget has focus. + +```dart +GamepadInterceptor( + onBeforeIntent: (intent) { + if (intent is ScrollIntent) { + if (intent.direction = AxisDirection.right) { + setState(() _value = min(100, _value + 10)); + } else if (intent.direction = AxisDirection.left) { + setState(() _value = max(0, _value - 10)); + } + // Block actual emit of ScrollIntent + return false; + } + // Allow other intents such as focus change to occur + return true; + } + child: Slider( + value: _value, + max: 100, + // This setState never occur by Gamepad input, but is good to allow keyboard/mouse + // input as well. + onChange: (value) => setState(() => _value = value), + ) +) +``` + +Note that `GamepadInterceptor` must be placed below the `GamepadControl` widget in the +widget tree. + + +### Changing the Gamepad bindings + +You can customize the default gamepad bindings by providing a map between GamepadActivator +and any Intent. + +```dart +GamepadControl( + shortcuts: { + GamepadActivatorButton.a(): ActivateIntent(), + GamepadActivatorButton.b(): DismissIntent(), + // In addition to the .a, .b, .x, .. constructors you can pass in a GamepadButton + GamepadActivatorButton(GamepadButton.x): DismissIntent(), + GamepadActivatorButton.bumperLeft(): PreviousFocusIntent(), + GamepadActivatorButton.bumperRight(): NextFocusIntent(), + GamepadActivatorAxis.rightStickUp(): ScrollIntent( + direction: AxisDirection.up, + ), + // You can configure an axis with its threshold if you want. + GamepadActivatorAxis(GamepadAxis.rightStickY, -0.2): ScrollIntent( + direction: AxisDirection.down, + ), + }, + child: child, +) +``` + + +### Temporary disabling Gamepad input + +From onBeforeIntent in a GamepadInterceptor or the GamepadControl widget you can return +false to block an intent from being emitted. + +On GamepadControl you may also set ignoreEvents = true to an an earlier level temporarily +block all Gamepad input processing. When ignoreEvents is reset to false, all axis input +is reset to default (non-activated) state. diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 778ca6085..2822fe076 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -1,305 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/pages/home_page.dart'; +import 'package:flutter_gamepads_example/pages/settings_page.dart'; +import 'package:flutter_gamepads_example/theme.dart'; import 'package:gamepads/gamepads.dart'; void main() { runApp(const MyApp()); } -/// This app has been AI generated which took a few attempts to iron out some -/// weirdness, but still can have some weird stuff in it. class MyApp extends StatelessWidget { const MyApp({super.key}); - @override - Widget build(BuildContext context) { - final baseTheme = ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), - useMaterial3: true, - ); - - return MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Flutter Gamepads Demo', - theme: baseTheme.copyWith( - focusColor: Colors.orange, - inputDecorationTheme: InputDecorationTheme( - border: const OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Colors.orange, - width: 3, - ), - ), - ), - navigationBarTheme: const NavigationBarThemeData( - indicatorColor: Color(0x332196F3), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return const BorderSide( - color: Colors.orange, - width: 3, - ); - } - return BorderSide.none; - }), - ), - ), - cardTheme: CardThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - initialRoute: '/', - routes: { - '/': (context) => const HomePage(), - '/settings': (context) => const SettingsPage(), - }, - ); - } -} - -class AppShell extends StatelessWidget { - const AppShell({ - required this.title, - required this.index, - required this.child, - super.key, - }); - - final String title; - final int index; - final Widget child; - - void _goToPage(BuildContext context, int newIndex) { - if (newIndex == 0) { - Navigator.pushReplacementNamed(context, '/'); - } else { - Navigator.pushReplacementNamed(context, '/settings'); - } - } - @override Widget build(BuildContext context) { return GamepadControl( - child: Scaffold( - appBar: AppBar( - title: Text(title), - centerTitle: true, - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), - ), - bottomNavigationBar: NavigationBar( - selectedIndex: index, - onDestinationSelected: (value) => _goToPage(context, value), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - NavigationDestination( - icon: Icon(Icons.tune_outlined), - selectedIcon: Icon(Icons.tune), - label: 'Settings', - ), - ], - ), - ), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return AppShell( - title: 'Flutter Gamepads sample app', - index: 0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Flutter Gamepads sample app', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - const Text( - 'A short sample app for flutter gamepad testing.', - ), - ], - ), - ), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('List of gamepad buttons'), - content: SizedBox( - height: 200, - width: 200, - child: ListView( - children: GamepadButton.values - .map((b) => Text(b.name)) - .toList(), - ), - ), - actions: [ - FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), - ); - }, - child: const Text('Show dialog'), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Primary action triggered')), - ); - }, - child: const Text('Show snackbar'), - ), - ], - ), - ); - } -} - -class SettingsPage extends StatefulWidget { - const SettingsPage({super.key}); - - @override - State createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - double volume = 50; - bool vibrationEnabled = true; - String selectedMode = 'Adventure'; - final TextEditingController nameController = TextEditingController( - text: 'Player One', - ); - - final List gameModes = ['Adventure', 'Arcade', 'Challenge']; - - @override - void dispose() { - nameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AppShell( - title: 'Settings', - index: 1, - child: ListView( - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration( - labelText: 'Player name', - prefixIcon: Icon(Icons.person_outline), - ), - ), - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Volume: ${volume.round()}'), - Slider( - value: volume, - max: 100, - divisions: 10, - label: volume.round().toString(), - onChanged: (value) { - setState(() { - volume = value; - }); - }, - ), - ], - ), - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: selectedMode, - decoration: const InputDecoration(labelText: 'Game mode'), - items: gameModes - .map( - (mode) => DropdownMenuItem( - value: mode, - child: Text(mode), - ), - ) - .toList(), - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - selectedMode = value; - }); - }, - ), - const SizedBox(height: 8), - SwitchListTile( - title: const Text('Enable vibration'), - value: vibrationEnabled, - onChanged: (value) { - setState(() { - vibrationEnabled = value; - }); - }, - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Saved ${nameController.text} / $selectedMode / ${volume.round()}', - ), - ), - ); - }, - child: const Text('Save settings'), - ), - ], + child: MaterialApp( + theme: appTheme(), + initialRoute: '/', + routes: { + '/': (context) => const HomePage(), + '/settings': (context) => const SettingsPage(), + }, ), ); } diff --git a/packages/flutter_gamepads/example/lib/pages/home_page.dart b/packages/flutter_gamepads/example/lib/pages/home_page.dart new file mode 100644 index 000000000..8921e5c31 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/pages/home_page.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: Drawer( + backgroundColor: Colors.indigo[200], + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: AlignmentGeometry.centerRight, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Icon(Icons.chevron_left, semanticLabel: 'Close'), + ), + ), + const SizedBox(height: 50), + FilledButton( + autofocus: true, + onPressed: () => onGotoSettings(context), + child: const Text('Settings'), + ), + ], + ), + ), + ), + appBar: AppBar( + title: const Text('Flutter Gamepads sample app'), + ), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + const Card( + child: Padding( + padding: EdgeInsets.all(20), + child: Text('A short sample app for flutter gamepads testing'), + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onShowDialog(context), + child: const Text('Show dialog'), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onShowSnackbar(context), + child: const Text('Show snackbar'), + ), + const SizedBox(height: 20), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls', + style: Theme.of(context).textTheme.titleLarge, + ), + Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + }, + children: [ + ...gamepadInfo(), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + List gamepadInfo() { + final shortcuts = GamepadControl(child: Container()).shortcuts; + return shortcuts.keys.map((key) { + var activator = ''; + if (key is GamepadActivatorButton) { + activator += 'Button ${key.button.name}'; + } else if (key is GamepadActivatorAxis) { + activator += 'Axis ${key.axis.name}'; + } + + final intent = shortcuts[key]; + final intentText = intent.toString().split('Intent')[0]; + + return TableRow( + children: [ + Text(activator), + Padding( + padding: const EdgeInsets.only(left: 30), + child: Text('→ ${intentText}'), + ), + ], + ); + }).toList(); + } + + void onShowDialog(BuildContext context) { + final controller = ScrollController(); + showDialog( + context: context, + builder: (context) => GamepadInterceptor( + onBeforeIntent: (intent) { + // The ListView just contains text and never therefore receives focus. + // Using GamepadInterceptor we can still support scrolling this + // ListView. + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.up) { + controller.jumpTo(controller.offset - 100); + } else if (intent.direction == AxisDirection.down) { + controller.jumpTo(controller.offset + 100.0); + } + return false; + } + return true; + }, + child: AlertDialog( + title: const Text('List of gamepad buttons'), + content: SizedBox( + height: 200, + width: 200, + child: ListView( + controller: controller, + children: GamepadButton.values.map((b) => Text(b.name)).toList(), + ), + ), + actions: [ + FilledButton( + autofocus: true, + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + + void onShowSnackbar(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Primary action triggered')), + ); + } + + void onGotoSettings(BuildContext context) { + Navigator.of(context).pop(); + Navigator.of(context).pushNamed('/settings'); + } +} diff --git a/packages/flutter_gamepads/example/lib/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/pages/settings_page.dart new file mode 100644 index 000000000..81f913a2c --- /dev/null +++ b/packages/flutter_gamepads/example/lib/pages/settings_page.dart @@ -0,0 +1,157 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final TextEditingController nameController = TextEditingController( + text: 'Player One', + ); + FocusNode volumeFocusNode = FocusNode(); + final volumeHasFocus = ValueNotifier(false); + double volume = 50; + String selectedGenre = 'Adventure'; + bool vibrationEnabled = true; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + nameController.dispose(); + volumeFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Player name', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: 20), + GamepadInterceptor( + onBeforeIntent: (intent) { + // The Slider widget does not itself support any public Intent to + // control it. + // + // So instead we intercept that GamepadControl is about to emit a + // ScrollIntent and implement changing the Slider value ourself. + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.right) { + setState(() => volume = min(100, volume + 10)); + } else if (intent.direction == AxisDirection.left) { + setState(() => volume = max(0, volume - 10)); + } + return false; + } + return true; + }, + child: Slider.adaptive( + focusNode: volumeFocusNode, + max: 100, + divisions: 10, + label: 'Volume: ${volume.round()}', + value: volume, + onChanged: (value) => setState(() => volume = value), + ), + ), + const SizedBox(height: 20), + ListTile( + title: const Text('Genre'), + trailing: SizedBox( + width: 150, + child: DropdownButtonFormField( + initialValue: selectedGenre, + items: + [ + 'Adventure', + 'Simulation', + 'RPG', + ] + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(s), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() => selectedGenre = value); + } + }, + ), + ), + ), + const SizedBox(height: 20), + SwitchListTile( + title: const Text('Vibration'), + value: vibrationEnabled, + onChanged: (value) => setState(() => vibrationEnabled = value), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: FilledButton( + style: Theme.of(context).filledButtonTheme.style!.copyWith( + backgroundColor: WidgetStatePropertyAll(Colors.grey[600]), + ), + onPressed: onReset, + child: const Text('Reset settings'), + ), + ), + const SizedBox(width: 20), + Expanded( + child: FilledButton( + onPressed: () => onSave(context), + child: const Text('Save settings'), + ), + ), + ], + ), + ], + ), + ); + } + + void onReset() { + setState(() { + nameController.text = 'Player One'; + volume = 50; + selectedGenre = 'Adventure'; + vibrationEnabled = true; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings reset')), + ); + } + + void onSave(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Saved ${nameController.text} / $selectedGenre / ${volume.round()}', + ), + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/pages/tab_shell.dart b/packages/flutter_gamepads/example/lib/pages/tab_shell.dart new file mode 100644 index 000000000..da74a4d48 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/pages/tab_shell.dart @@ -0,0 +1,18 @@ + +import 'package:flutter/material.dart'; + +class TabShell extends StatefulWidget { + + + const TabShell({super.key}); + + @override + State createState() => _TabShellState(); +} + +class _TabShellState extends State { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} \ No newline at end of file diff --git a/packages/flutter_gamepads/example/lib/theme.dart b/packages/flutter_gamepads/example/lib/theme.dart new file mode 100644 index 000000000..bccdf81b5 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +ThemeData appTheme() { + final theme = ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + ); + const focusColor = Colors.orange; + final focusBorder = WidgetStateBorderSide.resolveWith((state) { + if (state.contains(WidgetState.focused)) { + return const BorderSide(color: focusColor, width: 3); + } + return const BorderSide(color: Colors.transparent, width: 3); + }); + return theme.copyWith( + focusColor: focusColor, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[500]), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + side: focusBorder, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[500]), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + side: focusBorder, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[100]), + foregroundColor: const WidgetStatePropertyAll(Colors.black), + side: focusBorder, + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: focusColor, + width: 3, + ), + ), + ), + cardTheme: CardThemeData( + color: Colors.grey[200], + ), + ); +} diff --git a/packages/flutter_gamepads/lib/flutter_gamepads.dart b/packages/flutter_gamepads/lib/flutter_gamepads.dart index 110fd3c6d..5d63a070f 100644 --- a/packages/flutter_gamepads/lib/flutter_gamepads.dart +++ b/packages/flutter_gamepads/lib/flutter_gamepads.dart @@ -1 +1,3 @@ +export 'src/gamepad_activator.dart'; export 'src/gamepad_control.dart'; +export 'src/gamepad_interceptor.dart'; diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index cf88f94e1..91b3e0483 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_gamepads/src/gamepad_activator.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:gamepads/gamepads.dart'; /// Wrap your widget tree with this widget to allow users @@ -21,6 +21,10 @@ class GamepadControl extends StatefulWidget { /// Called just before an Intent is invoked. Return false to block /// emitting the Intent. + /// + /// Additionally, you can wrap something deep in your tree with + /// [GamepadInterceptor] widget to receive onBeforeIntent locally + /// in that build context. this.onBeforeIntent, /// If set to true, the gamepad control is temporarily disabled. It still @@ -52,18 +56,18 @@ class GamepadControl extends StatefulWidget { GamepadActivatorAxis.rightStickRight(): ScrollIntent( direction: AxisDirection.right, ), - GamepadActivatorAxis.leftStickLeft(): DirectionalFocusIntent( - TraversalDirection.left, - ), - GamepadActivatorAxis.leftStickRight(): DirectionalFocusIntent( - TraversalDirection.right, - ), - GamepadActivatorAxis.leftStickDown(): DirectionalFocusIntent( - TraversalDirection.down, - ), - GamepadActivatorAxis.leftStickUp(): DirectionalFocusIntent( - TraversalDirection.up, - ), +// GamepadActivatorAxis.leftStickLeft(): DirectionalFocusIntent( +// TraversalDirection.left, +// ), +// GamepadActivatorAxis.leftStickRight(): DirectionalFocusIntent( +// TraversalDirection.right, +// ), +// GamepadActivatorAxis.leftStickDown(): DirectionalFocusIntent( +// TraversalDirection.down, +// ), +// GamepadActivatorAxis.leftStickUp(): DirectionalFocusIntent( +// TraversalDirection.up, +// ), }, super.key, @@ -138,8 +142,7 @@ class _GamepadControlState extends State { _updatePreviousAxisValues(event); return; } - // Same lookup as ShortcutManager.handleKeypress - final primaryFocus = WidgetsBinding.instance.focusManager.primaryFocus; + // Same lookup as [ShortcutManager.handleKeypress] final focusedContext = primaryFocus?.context; // Allow previous/next to use parent context when focusContext is null // to allow user to focus something even when there is no autofocus. @@ -148,19 +151,11 @@ class _GamepadControlState extends State { ((intent is PreviousFocusIntent || intent is NextFocusIntent) ? context : null); + if (activateContext != null) { - var emitEvent = true; - if (widget.onBeforeIntent != null) { - emitEvent = widget.onBeforeIntent!(intent); - } + final emitEvent = _checkEmit(activateContext, intent); if (emitEvent) { - if (intent is PreviousFocusIntent) { - FocusScope.of(activateContext).previousFocus(); - } else if (intent is NextFocusIntent) { - FocusScope.of(activateContext).nextFocus(); - } else { - Actions.maybeInvoke(activateContext, intent); - } + Actions.maybeInvoke(activateContext, intent); } } @@ -201,6 +196,19 @@ class _GamepadControlState extends State { return null; } + bool _checkEmit(BuildContext activateContext, Intent intent) { + final interceptor = activateContext + .findAncestorWidgetOfExactType(); + var emit = true; + if (interceptor != null) { + emit = interceptor.onBeforeIntent(intent); + } + if (emit && widget.onBeforeIntent != null) { + emit = widget.onBeforeIntent!(intent); + } + return emit; + } + void _updatePreviousAxisValues(NormalizedGamepadEvent event) { if (event.axis != null) { previousAxisValue[event.axis!] = event.value; diff --git a/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart new file mode 100644 index 000000000..edef89e82 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// Wrap part of your widget tree with this widget to be able to +/// receive onBeforeIntent locally near the widget you want to control. +/// +/// This has to wrap the widget that holds the FocusNode. Eg. to wrap +/// a Slider() or other control you want to setup gamepad support for. +class GamepadInterceptor extends StatelessWidget { + final Widget child; + final bool Function(Intent) onBeforeIntent; + + const GamepadInterceptor({ + /// Called just before an Intent is invoked. Return false to block + /// emitting the Intent. + required this.onBeforeIntent, + + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return child; + } +} From 72f89b5cf3fcd2febf3863f0313aff42140cd272 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 16:43:42 +0200 Subject: [PATCH 05/65] docs: Update REAdME.md --- packages/flutter_gamepads/README.md | 42 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 4ae56012d..07a380046 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -10,8 +10,8 @@ similar to using the Tab key, but using the D-pad buttons on their gamepad. Seve 'simple' widgets like Buttons, DropdownMenu, Switch etc. just works. Some more complex interactive widgets like eg. the Slider widget needs some special attention -to support. Another situation is when you have a Scroll view that doesn't receive focus. This -package provides a `GamepadInterceptor` widget that you can use to handle those situations. +to support. This package provides a `GamepadInterceptor` widget that you can use to handle +those situations. ### Default bindings @@ -109,3 +109,41 @@ false to block an intent from being emitted. On GamepadControl you may also set ignoreEvents = true to an an earlier level temporarily block all Gamepad input processing. When ignoreEvents is reset to false, all axis input is reset to default (non-activated) state. + + +## Implementation strategy recommendation + +### Step 1 - Clear focus indicators + +Use the TAB key on your keyboard and step through your app and verify that your widgets +clearly show if they are focused or not. + +You may have to update your Theme and add an expressive border of the focused buttons for +example. + +If you notice that some widgets never receives focus you have to resolve this, by making +them focusable and verify with the TAB key this works. + + +### Step 2 - Add default GamepadControl + +Start with wrapping your MaterialApp or similar with the `GamepadControl` and then +try out your app with a gamepad. Take notice of which widgets in your app that doesn't +work out-of-the box. + +```dart +GamepadControl( + child: MaterialApp(), +) +``` + + +### Step 3 - Add interceptors where needed + +Then, wrap those problematic widgets with `GamepadInterceptor` and ensure that the widget +itself can receive focus. + +Use onBeforeIntent to catch eg. the ScrollIntent and use that to implement interaction +with your widget. + +Then test and repeat. From d20a7189cef66d8c1e4851375e1bdcf413bd80b8 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 17:01:13 +0200 Subject: [PATCH 06/65] test: Add test for GamepadInterceptor --- .../test/flutter_gamepads_test.dart | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart index 6ccbc59c3..b459c301c 100644 --- a/packages/flutter_gamepads/test/flutter_gamepads_test.dart +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gamepads/src/gamepad_control.dart'; +import 'package:flutter_gamepads/src/gamepad_interceptor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gamepads/gamepads.dart'; @@ -15,10 +16,11 @@ final platformInterface = enum _UiButton { noButton, first, second } void main() { + Gamepads.normalizer = GamepadNormalizer.forPlatform( + GamepadPlatform.windows, + ); + testWidgets('GamepadControl', (WidgetTester tester) async { - Gamepads.normalizer = GamepadNormalizer.forPlatform( - GamepadPlatform.windows, - ); var lastButtonPressed = _UiButton.noButton; var beforeInvokeActivate = false; var beforeInvokeDismiss = false; @@ -128,6 +130,78 @@ void main() { expect(beforeInvokeActivate, isFalse); expect(beforeInvokeDismiss, isFalse); }); + + testWidgets('GamepadInterceptor', (WidgetTester tester) async { + var rootBeforeIntentCalled = false; + var rootEmit = true; + var interceptorBeforeIntentCalled = false; + var interceptorEmit = true; + final secondFocusNode = FocusNode(); + var secondPressed = false; + final widget = MaterialApp( + home: GamepadControl( + onBeforeIntent: (intent) { + rootBeforeIntentCalled = true; + return rootEmit; + }, + child: Column( + children: [ + ElevatedButton( + onPressed: () => {}, + child: const Text('Button'), + ), + GamepadInterceptor(onBeforeIntent: (intent) { + interceptorBeforeIntentCalled = true; + return interceptorEmit; + }, child: ElevatedButton( + focusNode: secondFocusNode, + onPressed: () { secondPressed = true; }, + child: const Text('Second'), + )) + ], + ), + ), + ); + + await tester.pumpWidget(widget); + await _event('dpadDown'); + await _event('dpadDown'); + await tester.pumpAndSettle(); + + expect(secondFocusNode.hasFocus, isTrue); + expect(secondPressed, isFalse); + + // Emit gamepad event => should call both onBeforeIntent + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + await _event('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isTrue); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isTrue); + + // Return false only from the root onBeforeIntent + rootEmit = false; + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + secondPressed = false; + await _event('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isTrue); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isFalse); + + // Return false only from the interceptor onBeforeIntent + rootEmit = true; + interceptorEmit = false; + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + await _event('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isFalse); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isFalse); + }); } Future _event(String key) async { From c8902c7d6b96e395aa434676be70aaf4d931646d Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 18:22:00 +0200 Subject: [PATCH 07/65] feat: Input repeat --- .../lib/src/gamepad_control.dart | 169 +++++++++--------- .../test/flutter_gamepads_test.dart | 35 ++-- 2 files changed, 104 insertions(+), 100 deletions(-) diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index 91b3e0483..c0e87828b 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -16,6 +16,9 @@ class GamepadControl extends StatefulWidget { final Map shortcuts; + final Duration initialRepeatDelay; + final Duration repeatedRepeatDelay; + const GamepadControl({ required this.child, @@ -56,20 +59,15 @@ class GamepadControl extends StatefulWidget { GamepadActivatorAxis.rightStickRight(): ScrollIntent( direction: AxisDirection.right, ), -// GamepadActivatorAxis.leftStickLeft(): DirectionalFocusIntent( -// TraversalDirection.left, -// ), -// GamepadActivatorAxis.leftStickRight(): DirectionalFocusIntent( -// TraversalDirection.right, -// ), -// GamepadActivatorAxis.leftStickDown(): DirectionalFocusIntent( -// TraversalDirection.down, -// ), -// GamepadActivatorAxis.leftStickUp(): DirectionalFocusIntent( -// TraversalDirection.up, -// ), + }, + /// Delay after first input until first input repeat occurs. + this.initialRepeatDelay = const Duration(milliseconds: 700), + + /// Delay after the first input repetition to the next reptilton and beyond. + this.repeatedRepeatDelay = const Duration(milliseconds: 200), + super.key, }); @@ -80,16 +78,13 @@ class GamepadControl extends StatefulWidget { class _GamepadControlState extends State { StreamSubscription? _subscription; - /// abs() of the lowest minThreshold of any GamepadActivatorAxis - /// used by a shortcut or null if there are no axis shortcuts. - double? _minAxisThreshold; - final Map previousAxisValue = {}; + final Map _previousAxisValue = {}; + final Map _repeat = {}; @override void initState() { super.initState(); _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); - _minAxisThreshold = _resolveMinAxisThreshold(); } @override @@ -101,33 +96,11 @@ class _GamepadControlState extends State { @override void didUpdateWidget(covariant GamepadControl oldWidget) { super.didUpdateWidget(oldWidget); - _minAxisThreshold = _resolveMinAxisThreshold(); if (widget.ignoreEvents) { - previousAxisValue.clear(); + _previousAxisValue.clear(); } } - double? _resolveMinAxisThreshold() { - return widget.shortcuts.keys.fold(null, ( - double? prev, - GamepadActivator activator, - ) { - switch (activator) { - case final GamepadActivatorAxis axisActivator: - final absActivatorMinThreshold = axisActivator.minThreshold.abs(); - if (prev == null) { - return absActivatorMinThreshold; - } - if (absActivatorMinThreshold < prev) { - return absActivatorMinThreshold; - } - case _: - break; - } - return prev; - }); - } - @override Widget build(BuildContext context) { return widget.child; @@ -137,81 +110,107 @@ class _GamepadControlState extends State { if (widget.ignoreEvents == true) { return; } - final intent = _find(event); - if (intent == null) { - _updatePreviousAxisValues(event); - return; - } - // Same lookup as [ShortcutManager.handleKeypress] - final focusedContext = primaryFocus?.context; - // Allow previous/next to use parent context when focusContext is null - // to allow user to focus something even when there is no autofocus. - final activateContext = - focusedContext ?? - ((intent is PreviousFocusIntent || intent is NextFocusIntent) - ? context - : null); - - if (activateContext != null) { - final emitEvent = _checkEmit(activateContext, intent); - if (emitEvent) { - Actions.maybeInvoke(activateContext, intent); + final intents = _find(event); + for (final (intent, activated, canceled) in intents) { + if (canceled) { + _repeat[intent]?.cancel(); + _repeat.remove(intent); + } + if (activated) { + _maybeInvokeIntent(intent, const Duration(milliseconds: 700)); } } - _updatePreviousAxisValues(event); } - Intent? _find(NormalizedGamepadEvent event) { - final buttonPressed = event.button != null && event.value != 0; - final axisMaybeActive = - event.axis != null && - _minAxisThreshold != null && - event.value.abs() > _minAxisThreshold!.abs(); - if (!buttonPressed && !axisMaybeActive) { - return null; - } - + /// Find intents that match the given gamepad event. + /// + /// Return list of (Intent, activated, canceled) + List<(Intent, bool, bool)> _find(NormalizedGamepadEvent event) { + final result = <(Intent, bool, bool)>[]; for (final entry in widget.shortcuts.entries) { final activator = entry.key; switch (activator) { case final GamepadActivatorButton buttonActivator: if (buttonActivator.button == event.button) { - return entry.value; + result.add((entry.value, event.value != 0, event.value == 0)); } case final GamepadActivatorAxis axisActivator: if (axisActivator.axis == event.axis) { final activatorSign = axisActivator.minThreshold > 0; final inputSign = event.value > 0; - if (activatorSign == inputSign && + // Axis is activated when moving to from below Threshold to + // above it. + final axisActivated = + activatorSign == inputSign && event.value.abs() > axisActivator.minThreshold.abs() && - (!previousAxisValue.containsKey(axisActivator.axis) || - previousAxisValue[axisActivator.axis]!.abs() <= - axisActivator.minThreshold.abs())) { - return entry.value; - } + (!_previousAxisValue.containsKey(axisActivator.axis) || + _previousAxisValue[axisActivator.axis]!.abs() <= + axisActivator.minThreshold.abs()); + // Cancel is easier as duplicate cancels is not an issue. + final axisCanceled = axisActivator.minThreshold > 0 ? + event.value <= axisActivator.minThreshold + : event.value >= axisActivator.minThreshold ; + result.add((entry.value, axisActivated, axisCanceled)); } } } - return null; + return result; + } + + /// Invoke [intent] on target context if it is not being blocked by + /// onBeforeInvoke or CallbackInterceptor.onBeforeInvoke. Also + /// schedule a repeat after [repeatDuration]. + void _maybeInvokeIntent(Intent intent, Duration repeatDuration) { + final activateContext = _resolveInvokeContext(intent); + if (activateContext != null) { + // Activate the timer before calling _allowInvoke so that interceptors + // receive repeated input. + _repeat[intent] = Timer( + repeatDuration, + () => _onRepeat(intent), + ); + final allowInvoke = _allowInvoke(activateContext, intent); + if (allowInvoke) { + Actions.maybeInvoke(activateContext, intent); + } + } } - bool _checkEmit(BuildContext activateContext, Intent intent) { + /// Resolve target context for given [intent] + BuildContext? _resolveInvokeContext(Intent intent) { + // Same lookup as [ShortcutManager.handleKeypress] + final focusedContext = primaryFocus?.context; + // Allow previous/next to use parent context when focusContext is null + // to allow user to focus something even when there is no autofocus. + return focusedContext ?? + ((intent is PreviousFocusIntent || intent is NextFocusIntent) + ? context + : null); + } + + /// Check if invoking [intent] is permitted by + /// CallbackInterceptor.onBeforeInvoke and onBeforeInvoke. + bool _allowInvoke(BuildContext activateContext, Intent intent) { final interceptor = activateContext .findAncestorWidgetOfExactType(); - var emit = true; + var allow = true; if (interceptor != null) { - emit = interceptor.onBeforeIntent(intent); + allow = interceptor.onBeforeIntent(intent); } - if (emit && widget.onBeforeIntent != null) { - emit = widget.onBeforeIntent!(intent); + if (allow && widget.onBeforeIntent != null) { + allow = widget.onBeforeIntent!(intent); } - return emit; + return allow; + } + + void _onRepeat(Intent intent) { + _maybeInvokeIntent(intent, const Duration(milliseconds: 200)); } void _updatePreviousAxisValues(NormalizedGamepadEvent event) { if (event.axis != null) { - previousAxisValue[event.axis!] = event.value; + _previousAxisValue[event.axis!] = event.value; } } } diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart index b459c301c..8c35e1371 100644 --- a/packages/flutter_gamepads/test/flutter_gamepads_test.dart +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -63,7 +63,7 @@ void main() { ); await tester.pumpWidget(widget); - await _event('dpadDown'); + await _keyPress('dpadDown'); await tester.pumpAndSettle(); // First UI button has focus @@ -72,7 +72,7 @@ void main() { expect(beforeInvokeActivate, isFalse); // Emit 'a' gamepad button => should press the first UI button - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(aFocusNode.hasFocus, isTrue); expect(lastButtonPressed, equals(_UiButton.first)); @@ -82,7 +82,7 @@ void main() { // Emit 'dpadRight' gamepad button => should focus second UI button beforeInvokeActivate = false; lastButtonPressed = _UiButton.noButton; - await _event('dpadRight'); + await _keyPress('dpadRight'); await tester.pumpAndSettle(); expect(bFocusNode.hasFocus, isTrue); expect(lastButtonPressed, equals(_UiButton.noButton)); @@ -90,7 +90,7 @@ void main() { expect(beforeInvokeDismiss, isFalse); // Emit 'a' gamepad button => should press the second UI button - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(bFocusNode.hasFocus, isTrue); expect(lastButtonPressed, equals(_UiButton.second)); @@ -100,7 +100,7 @@ void main() { // Emit 'b' gamepad button => should call onBeforeInvoke with dismiss intent beforeInvokeActivate = false; lastButtonPressed = _UiButton.noButton; - await _event('b'); + await _keyPress('b'); await tester.pumpAndSettle(); expect(lastButtonPressed, equals(_UiButton.noButton)); expect(beforeInvokeActivate, isFalse); @@ -111,7 +111,7 @@ void main() { beforeInvokeActivate = false; beforeInvokeDismiss = false; lastButtonPressed = _UiButton.noButton; - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(lastButtonPressed, equals(_UiButton.noButton)); expect(beforeInvokeActivate, isTrue); @@ -124,7 +124,7 @@ void main() { beforeInvokeActivate = false; beforeInvokeDismiss = false; lastButtonPressed = _UiButton.noButton; - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(lastButtonPressed, equals(_UiButton.noButton)); expect(beforeInvokeActivate, isFalse); @@ -164,8 +164,8 @@ void main() { ); await tester.pumpWidget(widget); - await _event('dpadDown'); - await _event('dpadDown'); + await _keyPress('dpadDown'); + await _keyPress('dpadDown'); await tester.pumpAndSettle(); expect(secondFocusNode.hasFocus, isTrue); @@ -174,7 +174,7 @@ void main() { // Emit gamepad event => should call both onBeforeIntent rootBeforeIntentCalled = false; interceptorBeforeIntentCalled = false; - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(rootBeforeIntentCalled, isTrue); expect(interceptorBeforeIntentCalled, isTrue); @@ -185,7 +185,7 @@ void main() { rootBeforeIntentCalled = false; interceptorBeforeIntentCalled = false; secondPressed = false; - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(rootBeforeIntentCalled, isTrue); expect(interceptorBeforeIntentCalled, isTrue); @@ -196,7 +196,7 @@ void main() { interceptorEmit = false; rootBeforeIntentCalled = false; interceptorBeforeIntentCalled = false; - await _event('a'); + await _keyPress('a'); await tester.pumpAndSettle(); expect(rootBeforeIntentCalled, isFalse); expect(interceptorBeforeIntentCalled, isTrue); @@ -204,7 +204,12 @@ void main() { }); } -Future _event(String key) async { +Future _keyPress(String key) async { + await _event(key, 1.0); + await _event(key, 0.0); +} + +Future _event(String key, double value) async { final millis = DateTime.now().millisecondsSinceEpoch; await platformInterface.platformCallHandler( MethodCall('onGamepadEvent', { @@ -212,7 +217,7 @@ Future _event(String key) async { 'time': millis, 'type': 'button', 'key': key, - 'value': 1.0, + 'value': value, }), ); -} +} \ No newline at end of file From d4de40cef47e12963c89406ef6636ebbf52cb3f6 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 18:31:37 +0200 Subject: [PATCH 08/65] refactor: place gamepad_activator in api folder --- .../flutter_gamepads/lib/src/{ => api}/gamepad_activator.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/flutter_gamepads/lib/src/{ => api}/gamepad_activator.dart (100%) diff --git a/packages/flutter_gamepads/lib/src/gamepad_activator.dart b/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart similarity index 100% rename from packages/flutter_gamepads/lib/src/gamepad_activator.dart rename to packages/flutter_gamepads/lib/src/api/gamepad_activator.dart From 11ebc07029093a32ea42ba5d968dd43f0bcac9e1 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 18:43:44 +0200 Subject: [PATCH 09/65] fix: Forgot change --- packages/flutter_gamepads/lib/flutter_gamepads.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/lib/flutter_gamepads.dart b/packages/flutter_gamepads/lib/flutter_gamepads.dart index 5d63a070f..e92644fef 100644 --- a/packages/flutter_gamepads/lib/flutter_gamepads.dart +++ b/packages/flutter_gamepads/lib/flutter_gamepads.dart @@ -1,3 +1,3 @@ -export 'src/gamepad_activator.dart'; +export 'src/api/gamepad_activator.dart'; export 'src/gamepad_control.dart'; export 'src/gamepad_interceptor.dart'; From b9187b769e9a6b1aeeda5c39509e06fd1845d973 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 19:20:19 +0200 Subject: [PATCH 10/65] fix: analyze/format --- packages/flutter_gamepads/example/lib/main.dart | 1 - packages/flutter_gamepads/example/lib/pages/home_page.dart | 2 +- packages/flutter_gamepads/lib/src/gamepad_control.dart | 7 +++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 2822fe076..80f724606 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -3,7 +3,6 @@ import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:flutter_gamepads_example/pages/home_page.dart'; import 'package:flutter_gamepads_example/pages/settings_page.dart'; import 'package:flutter_gamepads_example/theme.dart'; -import 'package:gamepads/gamepads.dart'; void main() { runApp(const MyApp()); diff --git a/packages/flutter_gamepads/example/lib/pages/home_page.dart b/packages/flutter_gamepads/example/lib/pages/home_page.dart index 8921e5c31..9e4856f0b 100644 --- a/packages/flutter_gamepads/example/lib/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/pages/home_page.dart @@ -100,7 +100,7 @@ class HomePage extends StatelessWidget { Text(activator), Padding( padding: const EdgeInsets.only(left: 30), - child: Text('→ ${intentText}'), + child: Text('→ $intentText'), ), ], ); diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index c0e87828b..5d3f79082 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -59,7 +59,6 @@ class GamepadControl extends StatefulWidget { GamepadActivatorAxis.rightStickRight(): ScrollIntent( direction: AxisDirection.right, ), - }, /// Delay after first input until first input repeat occurs. @@ -148,9 +147,9 @@ class _GamepadControlState extends State { _previousAxisValue[axisActivator.axis]!.abs() <= axisActivator.minThreshold.abs()); // Cancel is easier as duplicate cancels is not an issue. - final axisCanceled = axisActivator.minThreshold > 0 ? - event.value <= axisActivator.minThreshold - : event.value >= axisActivator.minThreshold ; + final axisCanceled = axisActivator.minThreshold > 0 + ? event.value <= axisActivator.minThreshold + : event.value >= axisActivator.minThreshold; result.add((entry.value, axisActivated, axisCanceled)); } } From 87c3fc3d0a918aa2f55fe0b7b1fc42e61adbca3e Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 19:35:30 +0200 Subject: [PATCH 11/65] remove: unused code --- .../example/lib/pages/tab_shell.dart | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 packages/flutter_gamepads/example/lib/pages/tab_shell.dart diff --git a/packages/flutter_gamepads/example/lib/pages/tab_shell.dart b/packages/flutter_gamepads/example/lib/pages/tab_shell.dart deleted file mode 100644 index da74a4d48..000000000 --- a/packages/flutter_gamepads/example/lib/pages/tab_shell.dart +++ /dev/null @@ -1,18 +0,0 @@ - -import 'package:flutter/material.dart'; - -class TabShell extends StatefulWidget { - - - const TabShell({super.key}); - - @override - State createState() => _TabShellState(); -} - -class _TabShellState extends State { - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} \ No newline at end of file From b7a09513c33ed838b91356e609fcc5b358cdf0d4 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 19:36:32 +0200 Subject: [PATCH 12/65] chore: format code --- .../test/flutter_gamepads_test.dart | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart index 8c35e1371..a3c277bc4 100644 --- a/packages/flutter_gamepads/test/flutter_gamepads_test.dart +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -150,14 +150,19 @@ void main() { onPressed: () => {}, child: const Text('Button'), ), - GamepadInterceptor(onBeforeIntent: (intent) { - interceptorBeforeIntentCalled = true; - return interceptorEmit; - }, child: ElevatedButton( - focusNode: secondFocusNode, - onPressed: () { secondPressed = true; }, - child: const Text('Second'), - )) + GamepadInterceptor( + onBeforeIntent: (intent) { + interceptorBeforeIntentCalled = true; + return interceptorEmit; + }, + child: ElevatedButton( + focusNode: secondFocusNode, + onPressed: () { + secondPressed = true; + }, + child: const Text('Second'), + ), + ), ], ), ), @@ -220,4 +225,4 @@ Future _event(String key, double value) async { 'value': value, }), ); -} \ No newline at end of file +} From c2b49bc1a8b703675da5dd35a2db5f6c3f69cc86 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Sun, 29 Mar 2026 19:38:02 +0200 Subject: [PATCH 13/65] chore: format README.md --- packages/flutter_gamepads/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 07a380046..68e9a0d12 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -28,6 +28,7 @@ those situations. ## Usage + ### GamepadControl Wrap your widgets with GamepadControl to allow users to navigate it using their gamepad. It is @@ -113,6 +114,7 @@ is reset to default (non-activated) state. ## Implementation strategy recommendation + ### Step 1 - Clear focus indicators Use the TAB key on your keyboard and step through your app and verify that your widgets From cb477fc20336d154994673532ab7919709a51245 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 17:57:32 +0200 Subject: [PATCH 14/65] feat: a pure flutter game in the example of flutter_gamepads --- packages/flutter_gamepads/example/README.md | 8 +- .../flutter_gamepads/example/lib/main.dart | 2 + .../example/lib/pages/game_page.dart | 192 ++++++++++++++++++ .../example/lib/pages/home_page.dart | 9 + .../flutter_gamepads/example/pubspec.yaml | 1 + 5 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/pages/game_page.dart diff --git a/packages/flutter_gamepads/example/README.md b/packages/flutter_gamepads/example/README.md index b1411d4f5..53b21f776 100644 --- a/packages/flutter_gamepads/example/README.md +++ b/packages/flutter_gamepads/example/README.md @@ -1,3 +1,7 @@ -# gamepads_example +# flutter_gamepads example -A simple example project showcasing the gamepads plugin. +A simple example project showcasing the flutter_gamepads plugin. + +All gamepad input in this app is provided via `flutter_gamepads` package. It uses `GamepadInterceptor` +in some places to to provide gamepad support for certain widgets or scenarios that does not +work out of the box with just wrapping the app with `GamepadControl`. diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 80f724606..bea702003 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/pages/game_page.dart'; import 'package:flutter_gamepads_example/pages/home_page.dart'; import 'package:flutter_gamepads_example/pages/settings_page.dart'; import 'package:flutter_gamepads_example/theme.dart'; @@ -20,6 +21,7 @@ class MyApp extends StatelessWidget { routes: { '/': (context) => const HomePage(), '/settings': (context) => const SettingsPage(), + '/game': (context) => const GamePage(), }, ), ); diff --git a/packages/flutter_gamepads/example/lib/pages/game_page.dart b/packages/flutter_gamepads/example/lib/pages/game_page.dart new file mode 100644 index 000000000..1620cb75e --- /dev/null +++ b/packages/flutter_gamepads/example/lib/pages/game_page.dart @@ -0,0 +1,192 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; + +class GamePage extends StatelessWidget { + const GamePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Game')), + backgroundColor: Colors.green[900], + body: const _TickTackToe(), + ); + } +} + +class _TickTackToe extends StatefulWidget { + const _TickTackToe({super.key}); + + @override + State<_TickTackToe> createState() => _TickTackToeState(); +} + +enum _CellValue { + empty, + o, + x, +} + +class _TickTackToeState extends State<_TickTackToe> { + late final List<_CellValue> _board; + late final List _focusNodes; + var _player = _CellValue.x; + + @override + void initState() { + _board = List<_CellValue>.generate(9, (_) => _CellValue.empty); + _focusNodes = List.generate(9, (_) => FocusNode()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GamepadInterceptor( + onBeforeIntent: (intent) { + if (intent is ScrollIntent) { + moveFocus(intent.direction); + return false; + } + return true; + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: _cellSize * 3, + height: _cellSize * 3, + child: GridView.count( + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + children: _board + .mapIndexed( + (i, v) => _Cell( + onActivate: () => cellActivate(i), + value: v, + index: i, + focusNode: _focusNodes[i], + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 10), + Text( + 'Current player: ${switch (_player) { + _CellValue.x => 'x', + _CellValue.o => 'o', + _CellValue.empty => '', + }}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge!.copyWith( + color: Colors.white70, + ), + ), + ], + ), + ); + } + + void moveFocus(AxisDirection direction) { + var focusedIndex = _focusNodes.indexWhere( + (focusNode) => focusNode.hasFocus, + ); + if (focusedIndex == -1) { + focusedIndex = 4; + } else { + switch (direction) { + case AxisDirection.down: + if (focusedIndex < 6) { + focusedIndex += 3; + } + case AxisDirection.up: + if (focusedIndex > 2) { + focusedIndex -= 3; + } + case AxisDirection.left: + if (focusedIndex % 3 > 0) { + focusedIndex -= 1; + } + case AxisDirection.right: + if (focusedIndex % 3 < 2) { + focusedIndex += 1; + } + } + } + _focusNodes[focusedIndex].requestFocus(); + } + + void cellActivate(int index) { + setState(() { + _board[index] = _player; + _player = switch (_player) { + _CellValue.o => _CellValue.x, + _CellValue.x => _CellValue.o, + _CellValue.empty => throw Exception(), + }; + }); + } +} + +class _Cell extends StatefulWidget { + final void Function() onActivate; + final _CellValue value; + final int index; + final FocusNode focusNode; + const _Cell({ + required this.value, + required this.index, + required this.focusNode, + required this.onActivate, + super.key, + }); + + @override + State<_Cell> createState() => _CellState(); +} + +class _CellState extends State<_Cell> { + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + fixedSize: const WidgetStatePropertyAll( + Size(_cellSize, _cellSize), + ), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + backgroundColor: WidgetStatePropertyAll(Colors.brown[300]), + side: WidgetStateProperty.resolveWith((state) { + return BorderSide( + color: state.contains(WidgetState.focused) + ? Colors.orange + : Colors.brown, + width: 3, + ); + }), + ), + ), + ), + child: FilledButton( + focusNode: widget.focusNode, + onPressed: () { + widget.onActivate(); + }, + child: switch (widget.value) { + _CellValue.empty => Container(), + _CellValue.x => const Icon(Icons.close), + _CellValue.o => const Icon(Icons.circle_outlined), + }, + ), + ); + } +} + +const _cellSize = 70.0; diff --git a/packages/flutter_gamepads/example/lib/pages/home_page.dart b/packages/flutter_gamepads/example/lib/pages/home_page.dart index 9e4856f0b..43fcca3d9 100644 --- a/packages/flutter_gamepads/example/lib/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/pages/home_page.dart @@ -45,6 +45,11 @@ class HomePage extends StatelessWidget { ), ), const SizedBox(height: 20), + FilledButton( + onPressed: () => onShowGame(context), + child: const Text('Play game'), + ), + const SizedBox(height: 20), FilledButton( onPressed: () => onShowDialog(context), child: const Text('Show dialog'), @@ -107,6 +112,10 @@ class HomePage extends StatelessWidget { }).toList(); } + void onShowGame(BuildContext context) { + Navigator.of(context).pushNamed('/game'); + } + void onShowDialog(BuildContext context) { final controller = ScrollController(); showDialog( diff --git a/packages/flutter_gamepads/example/pubspec.yaml b/packages/flutter_gamepads/example/pubspec.yaml index f7f4854ef..903beafca 100644 --- a/packages/flutter_gamepads/example/pubspec.yaml +++ b/packages/flutter_gamepads/example/pubspec.yaml @@ -9,6 +9,7 @@ environment: sdk: ">=3.9.0 <4.0.0" dependencies: + collection: ^1.19.1 flutter: sdk: flutter flutter_gamepads: ^0.1.0 From 8d51ba54f95702b90c1661a2ef360db5f9e027f4 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 18:26:28 +0200 Subject: [PATCH 15/65] feat: send GamepadActivator to onBeforeIntent() --- packages/flutter_gamepads/README.md | 2 +- .../example/lib/pages/game_page.dart | 45 +++++++++++++------ .../example/lib/pages/home_page.dart | 2 +- .../example/lib/pages/settings_page.dart | 2 +- .../lib/src/gamepad_control.dart | 32 ++++++------- .../lib/src/gamepad_interceptor.dart | 3 +- .../test/flutter_gamepads_test.dart | 6 +-- 7 files changed, 56 insertions(+), 36 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 68e9a0d12..5fdb73496 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -48,7 +48,7 @@ If you want to intercept a Gamepad intent locally next to a Widget you can do so ```dart GamepadInterceptor( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { if (intent is ScrollIntent) { if (intent.direction = AxisDirection.right) { setState(() _value = min(100, _value + 10)); diff --git a/packages/flutter_gamepads/example/lib/pages/game_page.dart b/packages/flutter_gamepads/example/lib/pages/game_page.dart index 1620cb75e..e360e7642 100644 --- a/packages/flutter_gamepads/example/lib/pages/game_page.dart +++ b/packages/flutter_gamepads/example/lib/pages/game_page.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; class GamePage extends StatelessWidget { const GamePage({super.key}); @@ -33,6 +34,13 @@ class _TickTackToeState extends State<_TickTackToe> { late final List _focusNodes; var _player = _CellValue.x; + static const buttonDirMap = { + GamepadButton.dpadUp: AxisDirection.up, + GamepadButton.dpadDown: AxisDirection.down, + GamepadButton.dpadLeft: AxisDirection.left, + GamepadButton.dpadRight: AxisDirection.right, + }; + @override void initState() { _board = List<_CellValue>.generate(9, (_) => _CellValue.empty); @@ -43,11 +51,15 @@ class _TickTackToeState extends State<_TickTackToe> { @override Widget build(BuildContext context) { return GamepadInterceptor( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { if (intent is ScrollIntent) { moveFocus(intent.direction); return false; } + if (activator is GamepadActivatorButton && + buttonDirMap.keys.contains(activator.button)) { + return !moveFocus(buttonDirMap[activator.button]!); + } return true; }, child: Column( @@ -91,33 +103,38 @@ class _TickTackToeState extends State<_TickTackToe> { ); } - void moveFocus(AxisDirection direction) { - var focusedIndex = _focusNodes.indexWhere( + bool moveFocus(AxisDirection direction) { + final focusedIndex = _focusNodes.indexWhere( (focusNode) => focusNode.hasFocus, ); - if (focusedIndex == -1) { - focusedIndex = 4; + var newFocusedIndex = focusedIndex; + if (newFocusedIndex == -1) { + newFocusedIndex = 4; } else { switch (direction) { case AxisDirection.down: - if (focusedIndex < 6) { - focusedIndex += 3; + if (newFocusedIndex < 6) { + newFocusedIndex += 3; } case AxisDirection.up: - if (focusedIndex > 2) { - focusedIndex -= 3; + if (newFocusedIndex > 2) { + newFocusedIndex -= 3; } case AxisDirection.left: - if (focusedIndex % 3 > 0) { - focusedIndex -= 1; + if (newFocusedIndex % 3 > 0) { + newFocusedIndex -= 1; } case AxisDirection.right: - if (focusedIndex % 3 < 2) { - focusedIndex += 1; + if (newFocusedIndex % 3 < 2) { + newFocusedIndex += 1; } } } - _focusNodes[focusedIndex].requestFocus(); + if (newFocusedIndex != focusedIndex) { + _focusNodes[newFocusedIndex].requestFocus(); + return true; + } + return false; } void cellActivate(int index) { diff --git a/packages/flutter_gamepads/example/lib/pages/home_page.dart b/packages/flutter_gamepads/example/lib/pages/home_page.dart index 43fcca3d9..5ee0fb36f 100644 --- a/packages/flutter_gamepads/example/lib/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/pages/home_page.dart @@ -121,7 +121,7 @@ class HomePage extends StatelessWidget { showDialog( context: context, builder: (context) => GamepadInterceptor( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { // The ListView just contains text and never therefore receives focus. // Using GamepadInterceptor we can still support scrolling this // ListView. diff --git a/packages/flutter_gamepads/example/lib/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/pages/settings_page.dart index 81f913a2c..0585294b2 100644 --- a/packages/flutter_gamepads/example/lib/pages/settings_page.dart +++ b/packages/flutter_gamepads/example/lib/pages/settings_page.dart @@ -48,7 +48,7 @@ class _SettingsPageState extends State { ), const SizedBox(height: 20), GamepadInterceptor( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { // The Slider widget does not itself support any public Intent to // control it. // diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index 5d3f79082..7e9849758 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -12,7 +12,7 @@ import 'package:gamepads/gamepads.dart'; class GamepadControl extends StatefulWidget { final Widget child; final bool ignoreEvents; - final bool Function(Intent)? onBeforeIntent; + final bool Function(GamepadActivator, Intent)? onBeforeIntent; final Map shortcuts; @@ -110,13 +110,13 @@ class _GamepadControlState extends State { return; } final intents = _find(event); - for (final (intent, activated, canceled) in intents) { + for (final (activator, intent, activated, canceled) in intents) { if (canceled) { _repeat[intent]?.cancel(); _repeat.remove(intent); } if (activated) { - _maybeInvokeIntent(intent, const Duration(milliseconds: 700)); + _maybeInvokeIntent(activator, intent, const Duration(milliseconds: 700)); } } _updatePreviousAxisValues(event); @@ -125,14 +125,14 @@ class _GamepadControlState extends State { /// Find intents that match the given gamepad event. /// /// Return list of (Intent, activated, canceled) - List<(Intent, bool, bool)> _find(NormalizedGamepadEvent event) { - final result = <(Intent, bool, bool)>[]; + List<(GamepadActivator, Intent, bool, bool)> _find(NormalizedGamepadEvent event) { + final result = <(GamepadActivator, Intent, bool, bool)>[]; for (final entry in widget.shortcuts.entries) { final activator = entry.key; switch (activator) { case final GamepadActivatorButton buttonActivator: if (buttonActivator.button == event.button) { - result.add((entry.value, event.value != 0, event.value == 0)); + result.add((activator, entry.value, event.value != 0, event.value == 0)); } case final GamepadActivatorAxis axisActivator: if (axisActivator.axis == event.axis) { @@ -150,7 +150,7 @@ class _GamepadControlState extends State { final axisCanceled = axisActivator.minThreshold > 0 ? event.value <= axisActivator.minThreshold : event.value >= axisActivator.minThreshold; - result.add((entry.value, axisActivated, axisCanceled)); + result.add((activator, entry.value, axisActivated, axisCanceled)); } } } @@ -160,16 +160,18 @@ class _GamepadControlState extends State { /// Invoke [intent] on target context if it is not being blocked by /// onBeforeInvoke or CallbackInterceptor.onBeforeInvoke. Also /// schedule a repeat after [repeatDuration]. - void _maybeInvokeIntent(Intent intent, Duration repeatDuration) { + void _maybeInvokeIntent( + GamepadActivator activator, + Intent intent, Duration repeatDuration) { final activateContext = _resolveInvokeContext(intent); if (activateContext != null) { // Activate the timer before calling _allowInvoke so that interceptors // receive repeated input. _repeat[intent] = Timer( repeatDuration, - () => _onRepeat(intent), + () => _onRepeat(activator, intent), ); - final allowInvoke = _allowInvoke(activateContext, intent); + final allowInvoke = _allowInvoke(activateContext, activator, intent); if (allowInvoke) { Actions.maybeInvoke(activateContext, intent); } @@ -190,21 +192,21 @@ class _GamepadControlState extends State { /// Check if invoking [intent] is permitted by /// CallbackInterceptor.onBeforeInvoke and onBeforeInvoke. - bool _allowInvoke(BuildContext activateContext, Intent intent) { + bool _allowInvoke(BuildContext activateContext, GamepadActivator activator, Intent intent) { final interceptor = activateContext .findAncestorWidgetOfExactType(); var allow = true; if (interceptor != null) { - allow = interceptor.onBeforeIntent(intent); + allow = interceptor.onBeforeIntent(activator, intent); } if (allow && widget.onBeforeIntent != null) { - allow = widget.onBeforeIntent!(intent); + allow = widget.onBeforeIntent!(activator, intent); } return allow; } - void _onRepeat(Intent intent) { - _maybeInvokeIntent(intent, const Duration(milliseconds: 200)); + void _onRepeat(GamepadActivator activator, Intent intent) { + _maybeInvokeIntent(activator, intent, const Duration(milliseconds: 200)); } void _updatePreviousAxisValues(NormalizedGamepadEvent event) { diff --git a/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart index edef89e82..c8b8886cf 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/src/api/gamepad_activator.dart'; /// Wrap part of your widget tree with this widget to be able to /// receive onBeforeIntent locally near the widget you want to control. @@ -7,7 +8,7 @@ import 'package:flutter/material.dart'; /// a Slider() or other control you want to setup gamepad support for. class GamepadInterceptor extends StatelessWidget { final Widget child; - final bool Function(Intent) onBeforeIntent; + final bool Function(GamepadActivator activator, Intent intent) onBeforeIntent; const GamepadInterceptor({ /// Called just before an Intent is invoked. Return false to block diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart index a3c277bc4..ae5b2c493 100644 --- a/packages/flutter_gamepads/test/flutter_gamepads_test.dart +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -34,7 +34,7 @@ void main() { valueListenable: ignoreEvents, builder: (context, value, child) { return GamepadControl( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { if (intent is ActivateIntent) { beforeInvokeActivate = true; } else if (intent is DismissIntent) { @@ -140,7 +140,7 @@ void main() { var secondPressed = false; final widget = MaterialApp( home: GamepadControl( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { rootBeforeIntentCalled = true; return rootEmit; }, @@ -151,7 +151,7 @@ void main() { child: const Text('Button'), ), GamepadInterceptor( - onBeforeIntent: (intent) { + onBeforeIntent: (activator, intent) { interceptorBeforeIntentCalled = true; return interceptorEmit; }, From d399955f2750fa60ccd4cec74188379b7b082b7f Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 18:28:09 +0200 Subject: [PATCH 16/65] add: A Flame game example --- .../flutter_gamepads/flame_example/.gitignore | 53 ++++++ .../flutter_gamepads/flame_example/.metadata | 30 +++ .../flutter_gamepads/flame_example/README.md | 6 + .../flame_example/analysis_options.yaml | 1 + .../flame_example/assets/images/power_up.png | Bin 0 -> 900 bytes .../flame_example/assets/images/power_up.svg | 50 +++++ .../flame_example/assets/images/spaceship.png | Bin 0 -> 985 bytes .../flame_example/assets/images/spaceship.svg | 50 +++++ .../flame_example/lib/game.dart | 47 +++++ .../flame_example/lib/main.dart | 74 ++++++++ .../lib/overlays/help_overlay.dart | 41 +++++ .../flame_example/lib/overlays/overlays.dart | 6 + .../flame_example/lib/overlays/statusbar.dart | 70 +++++++ .../lib/overlays/upgrade_overlay.dart | 84 +++++++++ .../flame_example/lib/state/game_state.dart | 14 ++ .../flame_example/lib/world.dart | 172 ++++++++++++++++++ .../flame_example/pubspec.yaml | 28 +++ pubspec.yaml | 1 + 18 files changed, 727 insertions(+) create mode 100644 packages/flutter_gamepads/flame_example/.gitignore create mode 100644 packages/flutter_gamepads/flame_example/.metadata create mode 100644 packages/flutter_gamepads/flame_example/README.md create mode 100644 packages/flutter_gamepads/flame_example/analysis_options.yaml create mode 100644 packages/flutter_gamepads/flame_example/assets/images/power_up.png create mode 100644 packages/flutter_gamepads/flame_example/assets/images/power_up.svg create mode 100644 packages/flutter_gamepads/flame_example/assets/images/spaceship.png create mode 100644 packages/flutter_gamepads/flame_example/assets/images/spaceship.svg create mode 100644 packages/flutter_gamepads/flame_example/lib/game.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/main.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/state/game_state.dart create mode 100644 packages/flutter_gamepads/flame_example/lib/world.dart create mode 100644 packages/flutter_gamepads/flame_example/pubspec.yaml diff --git a/packages/flutter_gamepads/flame_example/.gitignore b/packages/flutter_gamepads/flame_example/.gitignore new file mode 100644 index 000000000..092376903 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + + +/test +/macos +/android +/ios +/web +/windows +/linux \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/.metadata b/packages/flutter_gamepads/flame_example/.metadata new file mode 100644 index 000000000..f0bb85c04 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: windows + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flutter_gamepads/flame_example/README.md b/packages/flutter_gamepads/flame_example/README.md new file mode 100644 index 000000000..b28d1f03a --- /dev/null +++ b/packages/flutter_gamepads/flame_example/README.md @@ -0,0 +1,6 @@ +# flutter_gamepads flame_example + +A simple flame game example project showcasing the flutter_gamepads plugin. + +`flutter_gamepads` package is used to allow gamepad control over the overlays in the game. +While the spaceship is controlled with `gamepads` events directly. diff --git a/packages/flutter_gamepads/flame_example/analysis_options.yaml b/packages/flutter_gamepads/flame_example/analysis_options.yaml new file mode 100644 index 000000000..ba5631f3b --- /dev/null +++ b/packages/flutter_gamepads/flame_example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/assets/images/power_up.png b/packages/flutter_gamepads/flame_example/assets/images/power_up.png new file mode 100644 index 0000000000000000000000000000000000000000..a9f16c6c9c4749613ffedc4ee4b7ceef85e447f8 GIT binary patch literal 900 zcmV-~1AF|5P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10})9? zK~z|U#g|)XR96&+zdhqbF)^q_MEYnS1WN;rbv(zIIWrkSBBr*fC~6cH@u?3*L`%UJ z5d`(YiVr^63sn)M7R7{CN`p=vF9c*#YP}I8rCy@rC7Cm4&fJy{0h2MH`;3!h!G1V< zFaH1EYyW$5mJ$&rGJF%t4ouQXYH3lEUp(V0rSQKMtiZ{O*X6&83+U_=H)5%oz|JpP z@hLSr=Uy*`e{M1#*x*Hg()B<%tR@9~bZ=P8N2}XLS7KlgR<+~g3e1beq)%6Xwg=R`Xu&_Fif?o=0T2;SqtReh>vB>r9w6E0rQ=aAVl|Oo(!d;D zJkPfv7MIg@n)8Q_;Jmpua|SG1iVD=SLl>{CYxpF|mP?mO{&|s625Mi!6e+MRyg=2B znSew>Y}aAs{zGIQ_m{5Cs)KM7N=@Z!EikVh7&)vazTMPLDw8br!u^{_I7BKKq_*N7 z7#W2E$&?MbeCfQM1_ge|rvLsB`O(8d6g?Iu{`EY$ zbe6VwT>dY+>?7ix0ul+aoeUfI?kDYJprIa7Md~X-t|b5<9+%%-hYLTQ!2R?Ss;g^Q zKCS`)@`G$W+ksnC4f!0_xC`j&mZ#Y)+rK|fs;Y{BX{ry$RRBQjGRL0ub1UsI)hCsy zf+qnYB96^E5ek*^f%l)%YjP*xXP@iRSP21Ioza`|ILV&68T2Tnl!&}ncdOkQja5u| acl-+9k + + + + + + + + + diff --git a/packages/flutter_gamepads/flame_example/assets/images/spaceship.png b/packages/flutter_gamepads/flame_example/assets/images/spaceship.png new file mode 100644 index 0000000000000000000000000000000000000000..69507b602d4915a22876dfcff0706c8a2a230457 GIT binary patch literal 985 zcmV;~119{5P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H117=A? zK~z|U#g|=ZTV)u>f6qDj=*h`BP1a_$>ql~ux}_v*Ep*fQwM89lD6X#EP;hgbww)gt z$Q%f(L-EcFZ}mnm#Ct(d6ucNiChTIec{7m?MFpiDl`c)ln&kCjjJ7n%4*f_TcrMQS zJpcFkz3+M72VPN1(M;2tT7A6$@t_=Q8h{g)kSIQN4B6Wh0DqauEk69VpAQ-iAR-sPDSyBG^QE~V0n2jo4^Kmr5Dd)g+nn@IvWBI4aj2w z54}&d-~Dd&z-SkmsdKg=03k0^ug3^zvNSds!(V~Pv`BDM0USeOi;BHRwyo548}LLM zQZyOgQ~<-rxnn(O0nh4KHje4lI6o}n-4uXiL@X+tIMIv0T70Js+f97848}GEpj6~^ zrXAJUQak5N0xRg{LlKeMS!)0g5xqrtXTrg(mzRk^*h@z=NM=I;9D~t#0AsNAKA=E0 zff>@=w`W%XmX`}}#j)JvzVHAd8bk4*3~mTO!jxS;o!HY6H`9SfgYhUWw$Awt0Wdwh z|7HwBcYUnjcrW^$3eWWgq-R|KaYLddMdnC%^}Ly4bzM-0quuB&F4}Wl0K>!Cm%7nS zeRb@MKPm6IMET5h3c2#?_;?gwS$HiOk-BH6CO|SGRz*1VY82n{@q7v1xJKcl-?*t@ z=;AHD%=}3H%G^qQmjRhJq*CDYngB`#CZ24AZOaq0@CFX{;+f|7vb=)^J=!<6>@7%SVCr* z&vPYmzvtEQnzsPZ`T`sRwDqpM#^FBNw*|ZA4m87>{yP32W#deL$600000NkvXX Hu0mjf+hoUW literal 0 HcmV?d00001 diff --git a/packages/flutter_gamepads/flame_example/assets/images/spaceship.svg b/packages/flutter_gamepads/flame_example/assets/images/spaceship.svg new file mode 100644 index 000000000..538a887ff --- /dev/null +++ b/packages/flutter_gamepads/flame_example/assets/images/spaceship.svg @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/packages/flutter_gamepads/flame_example/lib/game.dart b/packages/flutter_gamepads/flame_example/lib/game.dart new file mode 100644 index 000000000..64a8ed913 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/game.dart @@ -0,0 +1,47 @@ +import 'dart:ui'; + +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_flame_example/world.dart'; + +class MyGame extends FlameGame with HasKeyboardHandlerComponents { + final GameState gameState = GameState(); + + MyGame() : super(world: MyWorld()); + + bool get anyDialogOpen => + overlays.activeOverlays.contains(MyOverlays.help.name) || + overlays.activeOverlays.contains(MyOverlays.upgrade.name); + + void updateEnginePause() { + final shouldBePaused = + gameState.userPaused.value || anyDialogOpen; + if (shouldBePaused != paused) { + if (shouldBePaused) { + pauseEngine(); + } else { + resumeEngine(); + } + } + } + + void showOverlay(MyOverlays overlay) { + overlays.add(overlay.name); + updateEnginePause(); + } + + void hideOverlay(MyOverlays overlay) { + overlays.remove(overlay.name); + updateEnginePause(); + } + + void hideAllDialogs() { + overlays.removeAll([ + MyOverlays.help.name, + MyOverlays.upgrade.name, + ]); + updateEnginePause(); + } +} diff --git a/packages/flutter_gamepads/flame_example/lib/main.dart b/packages/flutter_gamepads/flame_example/lib/main.dart new file mode 100644 index 000000000..6979b8f5a --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/main.dart @@ -0,0 +1,74 @@ +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_flame_example/game.dart'; +import 'package:flutter_gamepads_flame_example/overlays/help_overlay.dart'; +import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_flame_example/overlays/statusbar.dart'; +import 'package:flutter_gamepads_flame_example/overlays/upgrade_overlay.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + final game = MyGame(); + return MaterialApp( + theme: + ThemeData.from( + colorScheme: ColorScheme.dark( + primary: Colors.orange[700]!, + surface: Color.lerp( Colors.orange[900], Colors.grey[800], 0.7)!, + ), + ).copyWith( + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusGeometry.circular(5), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + shape: WidgetStateProperty.resolveWith((state) { + return RoundedRectangleBorder( + side: BorderSide( + color: state.contains(WidgetState.focused) + ? Colors.lightGreenAccent + : Colors.transparent, + width: 4, + strokeAlign: -0.5, + ), + borderRadius: BorderRadiusGeometry.circular( + state.contains(WidgetState.focused) ? 2 : 5, + ), + ); + }), + ), + ), + ), + home: GamepadControl( + onBeforeIntent: (activator, intent) { + if (intent is DismissIntent && game.anyDialogOpen) { + game.hideAllDialogs(); + return false; + } + return true; + }, + child: GameWidget( + game: game, + initialActiveOverlays: [MyOverlays.statusbar.name], + overlayBuilderMap: { + MyOverlays.help.name: (context, MyGame game) => HelpOverlay(game), + MyOverlays.statusbar.name: (context, MyGame game) => + StatusBarOverlay(game), + MyOverlays.upgrade.name: (context, MyGame game) => + UpgradeOverlay(game), + }, + ), + ), + ); + } +} diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart b/packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart new file mode 100644 index 000000000..80c85854a --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_flame_example/game.dart'; +import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; + +class HelpOverlay extends StatelessWidget { + final MyGame game; + const HelpOverlay(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return FocusScope( + canRequestFocus: true, + autofocus: true, + child: AlertDialog( + title: const Text('Controls'), + content: const Text('''** Spaceship ** +Turn left: A, LeftArrow, Gamepad left stick +Turn right: D, RightArrow, Gamepad left stick +Accelerate: W, UpArrow, Gamepad right trigger +Brake: D, DownArrow, Gamepad left trigger + +Note: to brake, you need the turnRail upgrade. + +** Gamepad UI controls ** +Move focus: D-pad +Activate button: A +Close dialog: B +'''), + actions: [ + FilledButton( + autofocus: true, + onPressed: () { + game.hideOverlay(MyOverlays.help); + }, + child: const Text('Close'), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart b/packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart new file mode 100644 index 000000000..f7e04fa0b --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart @@ -0,0 +1,6 @@ + +enum MyOverlays { + help, + statusbar, + upgrade, +} \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart b/packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart new file mode 100644 index 000000000..2cf49267a --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_flame_example/game.dart'; +import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_flame_example/state/game_state.dart'; + +class StatusBarOverlay extends StatefulWidget { + final MyGame game; + + const StatusBarOverlay(this.game, {super.key}); + + @override + State createState() => _StatusBarOverlayState(); +} + +class _StatusBarOverlayState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + color: Theme.of(context).colorScheme.surface, + child: Row( + children: [ + FilledButton( + onPressed: onTogglePause, + child: ValueListenableBuilder( + valueListenable: widget.game.gameState.userPaused, + builder: (context, userPaused, child) { + return Icon(userPaused ? Icons.play_arrow : Icons.pause); + }, + ), + ), + const SizedBox(width: 5), + FilledButton( + onPressed: onShowUpgradeOverlay, + child: ValueListenableBuilder( + valueListenable: widget.game.gameState.powerUps, + builder: (context, value, child) { + return Row( + children: [ + Image.asset('assets/images/power_up.png'), + Text('$value'), + ], + ); + }, + ), + ), + const SizedBox(width: 5), + FilledButton( + onPressed: onShowHelpOverlay, + child: Text('Controls'), + ), + ], + ), + ); + } + + void onTogglePause() { + widget.game.gameState.userPaused.value = + !widget.game.gameState.userPaused.value; + widget.game.updateEnginePause(); + } + + void onShowUpgradeOverlay() { + widget.game.showOverlay(MyOverlays.upgrade); + } + + void onShowHelpOverlay() { + widget.game.showOverlay(MyOverlays.help); + } +} diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart b/packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart new file mode 100644 index 000000000..f19b3178e --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_flame_example/game.dart'; +import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_flame_example/state/game_state.dart'; + +class UpgradeOverlay extends StatelessWidget { + final MyGame game; + const UpgradeOverlay(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return FocusScope( + child: AlertDialog( + title: const Text('Upgrades'), + content: SizedBox( + width: 500, + height: 400, + child: ValueListenableBuilder( + valueListenable: game.gameState.powerUps, + builder: (context, powerUps, child) { + if (powerUps > 0) { + return UpgradesList(game); + } + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No PowerUp credits available, collect some power ups' + ' with your spaceship.', + ), + ], + ); + }, + ), + ), + actions: [ + FilledButton( + autofocus: true, + onPressed: () { + game.hideOverlay(MyOverlays.upgrade); + }, + child: const Text('Close'), + ), + ], + ), + ); + } +} + +class UpgradesList extends StatelessWidget { + final MyGame game; + const UpgradesList(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: game.gameState.installedUpgrades, + builder: (context, installedUpgrades, child) { + return ListView( + children: [ + ...SpaceshipUpgrades.values.map( + (upgrade) => Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: FilledButton( + onPressed: installedUpgrades.contains(upgrade) + ? null + : () { + game.gameState.installedUpgrades.value = { + ...installedUpgrades, + upgrade, + }; + game.gameState.powerUps.value -= 1; + game.hideOverlay(MyOverlays.upgrade); + }, + child: Text(upgrade.name), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/packages/flutter_gamepads/flame_example/lib/state/game_state.dart b/packages/flutter_gamepads/flame_example/lib/state/game_state.dart new file mode 100644 index 000000000..f2eda7d81 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/state/game_state.dart @@ -0,0 +1,14 @@ + +import 'package:flame/components.dart'; +import 'package:flutter/foundation.dart'; + +enum SpaceshipUpgrades { + autoBrake, + turnRail, +} + +class GameState extends Component { + final powerUps = ValueNotifier(0); + final installedUpgrades = ValueNotifier>({}); + final userPaused = ValueNotifier(false); +} \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/lib/world.dart b/packages/flutter_gamepads/flame_example/lib/world.dart new file mode 100644 index 000000000..7dfc9a736 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/lib/world.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gamepads_flame_example/game.dart'; +import 'package:flutter_gamepads_flame_example/state/game_state.dart'; +import 'package:gamepads/gamepads.dart'; + +class MyWorld extends World + with HasCollisionDetection, HasGameReference { + static const worldSizeX = 1000.0; + static const worldSizeY = 1000.0; + @override + FutureOr onLoad() { + final spaceShip = SpaceShip(); + + addAll([ + spaceShip, + ...List.generate( + 10, + (i) => PowerUp() + ..position = Vector2( + Random().nextDoubleBetween(-worldSizeX / 2, worldSizeX / 2), + Random().nextDoubleBetween(-worldSizeX / 2, worldSizeY / 2), + ), + ), + ]); + + game.camera.follow(spaceShip); + + return super.onLoad(); + } +} + +class SpaceShip extends SpriteComponent + with KeyboardHandler, CollisionCallbacks, HasGameReference { + double inputX = 0; + double inputY = 0; + Vector2 velocity = Vector2.zero(); + double dy = 0; + StreamSubscription? _subscription; + + static const rotationVelocity = 2.0; + static const accel = 50.0; + static const decel = 100.0; + static const autoBrakeDecel = 25.0; + static const gamepadDeadZone = 0.15; + + @override + Future onLoad() async { + sprite = await Sprite.load('spaceship.png'); + anchor = Anchor.center; + add(RectangleHitbox()); + _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); + return super.onLoad(); + } + + @override + void onRemove() { + _subscription?.cancel(); + super.onRemove(); + } + + @override + void update(double dt) { + _basicUpdate(dt); + super.update(dt); + } + + void _basicUpdate(double dt) { + bool hasTurnRail = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.turnRail, + ); + bool hasAutoBrake = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.autoBrake, + ); + + angle += inputX * dt * rotationVelocity; + + final angleX = cos(angle - pi / 2); + final angleY = sin(angle - pi / 2); + + final ddy = inputY > 0 ? accel : decel; + + if (hasTurnRail) { + var dy = velocity.length; + dy = max(0, dy + inputY * dt * (inputY > 0 ? accel : decel)); + velocity.x = angleX * dy; + velocity.y = angleY * dy; + } else { + velocity.x += angleX * dt * max(inputY, 0) * ddy; + velocity.y += angleY * dt * max(inputY, 0) * ddy; + } + + if (hasAutoBrake && inputY < 0.01 && velocity.length >= 0.001) { + velocity.clampLength(0, max(0, velocity.length - dt * autoBrakeDecel)); + } + + if (velocity.length > 0.001) { + position.x += velocity.x * dt; + position.y += velocity.y * dt; + } + } + + void _turnRailUpdate(double dt) { + angle += inputX * dt * rotationVelocity; + dy = max(0, dy + inputY * dt * (inputY > 0 ? accel : decel)); + + if (dy.abs() > 0.001) { + position.x += cos(angle - pi / 2) * dy * dt; + position.y += sin(angle - pi / 2) * dy * dt; + } + } + + @override + bool onKeyEvent(KeyEvent event, Set keysPressed) { + if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA)) { + inputX = -1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD)) { + inputX = 1; + } else { + inputX = 0; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || + keysPressed.contains(LogicalKeyboardKey.keyW)) { + inputY = 1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || + keysPressed.contains(LogicalKeyboardKey.keyS)) { + inputY = -1; + } else { + inputY = 0; + } + + return super.onKeyEvent(event, keysPressed); + } + + @override + void onCollision(Set intersectionPoints, PositionComponent other) { + if (other is PowerUp) { + game.world.remove(other); + game.gameState.powerUps.value += 1; + } + + super.onCollision(intersectionPoints, other); + } + + void onGamepadEvent(NormalizedGamepadEvent event) { + if (event.axis == GamepadAxis.leftStickX) { + inputX = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.rightTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.leftTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? -event.value : 0; + } + } +} + +class PowerUp extends SpriteComponent { + Future onLoad() async { + sprite = await Sprite.load('power_up.png'); + anchor = Anchor.center; + add(RectangleHitbox(collisionType: CollisionType.passive)); + return super.onLoad(); + } +} diff --git a/packages/flutter_gamepads/flame_example/pubspec.yaml b/packages/flutter_gamepads/flame_example/pubspec.yaml new file mode 100644 index 000000000..9f9f3e4e6 --- /dev/null +++ b/packages/flutter_gamepads/flame_example/pubspec.yaml @@ -0,0 +1,28 @@ +name: flutter_gamepads_flame_example +description: A simple game example project showcasing the flutter_gamepads plugin. +resolution: workspace +publish_to: 'none' + +version: 0.1.0 + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + flame: ^1.36.0 + flutter: + sdk: flutter + flutter_gamepads: ^0.1.0 + gamepads: ^0.1.10 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + + assets: + - assets/images/spaceship.png + - assets/images/power_up.png \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 8dee05e93..dfc88308c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,6 +3,7 @@ repository: https://github.com/flame-engine/gamepads workspace: - packages/flutter_gamepads - packages/flutter_gamepads/example + - packages/flutter_gamepads/flame_example - packages/gamepads - packages/gamepads/example - packages/gamepads_android From a6a8bcb4ba5a7a095d925fadc430121cea28371a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 19:51:51 +0200 Subject: [PATCH 17/65] refactor: Merge the two example folders and add a Example chooser menue infront --- .../assets/images/power_up.png | Bin .../assets/images/power_up.svg | 0 .../assets/images/spaceship.png | Bin .../assets/images/spaceship.svg | 0 .../{ => example/lib}/flame_example/README.md | 0 .../lib/flame_example}/game.dart | 9 +- .../lib/flame_example}/main.dart | 19 ++-- .../flame_example}/overlays/help_overlay.dart | 4 +- .../lib/flame_example}/overlays/overlays.dart | 0 .../flame_example}/overlays/statusbar.dart | 25 ++++- .../overlays/upgrade_overlay.dart | 6 +- .../lib/flame_example}/state/game_state.dart | 0 .../lib/flame_example}/world.dart | 4 +- .../example/lib/flutter_example/README.md | 7 ++ .../example/lib/flutter_example/main.dart | 30 +++++ .../pages/game_page.dart | 0 .../pages/home_page.dart | 31 +++--- .../pages/settings_page.dart | 0 .../lib/{ => flutter_example}/theme.dart | 0 .../flutter_gamepads/example/lib/main.dart | 105 +++++++++++++++--- .../flutter_gamepads/example/pubspec.yaml | 5 + .../flutter_gamepads/flame_example/.gitignore | 53 --------- .../flutter_gamepads/flame_example/.metadata | 30 ----- .../flame_example/analysis_options.yaml | 1 - .../flame_example/pubspec.yaml | 28 ----- pubspec.yaml | 1 - 26 files changed, 192 insertions(+), 166 deletions(-) rename packages/flutter_gamepads/{flame_example => example}/assets/images/power_up.png (100%) rename packages/flutter_gamepads/{flame_example => example}/assets/images/power_up.svg (100%) rename packages/flutter_gamepads/{flame_example => example}/assets/images/spaceship.png (100%) rename packages/flutter_gamepads/{flame_example => example}/assets/images/spaceship.svg (100%) rename packages/flutter_gamepads/{ => example/lib}/flame_example/README.md (100%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/game.dart (75%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/main.dart (78%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/overlays/help_overlay.dart (86%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/overlays/overlays.dart (100%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/overlays/statusbar.dart (68%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/overlays/upgrade_overlay.dart (91%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/state/game_state.dart (100%) rename packages/flutter_gamepads/{flame_example/lib => example/lib/flame_example}/world.dart (97%) create mode 100644 packages/flutter_gamepads/example/lib/flutter_example/README.md create mode 100644 packages/flutter_gamepads/example/lib/flutter_example/main.dart rename packages/flutter_gamepads/example/lib/{ => flutter_example}/pages/game_page.dart (100%) rename packages/flutter_gamepads/example/lib/{ => flutter_example}/pages/home_page.dart (91%) rename packages/flutter_gamepads/example/lib/{ => flutter_example}/pages/settings_page.dart (100%) rename packages/flutter_gamepads/example/lib/{ => flutter_example}/theme.dart (100%) delete mode 100644 packages/flutter_gamepads/flame_example/.gitignore delete mode 100644 packages/flutter_gamepads/flame_example/.metadata delete mode 100644 packages/flutter_gamepads/flame_example/analysis_options.yaml delete mode 100644 packages/flutter_gamepads/flame_example/pubspec.yaml diff --git a/packages/flutter_gamepads/flame_example/assets/images/power_up.png b/packages/flutter_gamepads/example/assets/images/power_up.png similarity index 100% rename from packages/flutter_gamepads/flame_example/assets/images/power_up.png rename to packages/flutter_gamepads/example/assets/images/power_up.png diff --git a/packages/flutter_gamepads/flame_example/assets/images/power_up.svg b/packages/flutter_gamepads/example/assets/images/power_up.svg similarity index 100% rename from packages/flutter_gamepads/flame_example/assets/images/power_up.svg rename to packages/flutter_gamepads/example/assets/images/power_up.svg diff --git a/packages/flutter_gamepads/flame_example/assets/images/spaceship.png b/packages/flutter_gamepads/example/assets/images/spaceship.png similarity index 100% rename from packages/flutter_gamepads/flame_example/assets/images/spaceship.png rename to packages/flutter_gamepads/example/assets/images/spaceship.png diff --git a/packages/flutter_gamepads/flame_example/assets/images/spaceship.svg b/packages/flutter_gamepads/example/assets/images/spaceship.svg similarity index 100% rename from packages/flutter_gamepads/flame_example/assets/images/spaceship.svg rename to packages/flutter_gamepads/example/assets/images/spaceship.svg diff --git a/packages/flutter_gamepads/flame_example/README.md b/packages/flutter_gamepads/example/lib/flame_example/README.md similarity index 100% rename from packages/flutter_gamepads/flame_example/README.md rename to packages/flutter_gamepads/example/lib/flame_example/README.md diff --git a/packages/flutter_gamepads/flame_example/lib/game.dart b/packages/flutter_gamepads/example/lib/flame_example/game.dart similarity index 75% rename from packages/flutter_gamepads/flame_example/lib/game.dart rename to packages/flutter_gamepads/example/lib/flame_example/game.dart index 64a8ed913..c74675fef 100644 --- a/packages/flutter_gamepads/flame_example/lib/game.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/game.dart @@ -2,14 +2,15 @@ import 'dart:ui'; import 'package:flame/game.dart'; import 'package:flame/input.dart'; -import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; -import 'package:flutter_gamepads_flame_example/state/game_state.dart'; -import 'package:flutter_gamepads_flame_example/world.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_example/flame_example/world.dart'; class MyGame extends FlameGame with HasKeyboardHandlerComponents { final GameState gameState = GameState(); + final void Function()? exitApp; - MyGame() : super(world: MyWorld()); + MyGame(this.exitApp) : super(world: MyWorld()); bool get anyDialogOpen => overlays.activeOverlays.contains(MyOverlays.help.name) || diff --git a/packages/flutter_gamepads/flame_example/lib/main.dart b/packages/flutter_gamepads/example/lib/flame_example/main.dart similarity index 78% rename from packages/flutter_gamepads/flame_example/lib/main.dart rename to packages/flutter_gamepads/example/lib/flame_example/main.dart index 6979b8f5a..97f968148 100644 --- a/packages/flutter_gamepads/flame_example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/main.dart @@ -1,22 +1,23 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; -import 'package:flutter_gamepads_flame_example/game.dart'; -import 'package:flutter_gamepads_flame_example/overlays/help_overlay.dart'; -import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; -import 'package:flutter_gamepads_flame_example/overlays/statusbar.dart'; -import 'package:flutter_gamepads_flame_example/overlays/upgrade_overlay.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/help_overlay.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/statusbar.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/upgrade_overlay.dart'; void main() { - runApp(const MyApp()); + runApp(const MyFlameApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class MyFlameApp extends StatelessWidget { + final void Function()? exitApp; + const MyFlameApp({this.exitApp, super.key}); @override Widget build(BuildContext context) { - final game = MyGame(); + final game = MyGame(exitApp); return MaterialApp( theme: ThemeData.from( diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart similarity index 86% rename from packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart rename to packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart index 80c85854a..dfc73ad28 100644 --- a/packages/flutter_gamepads/flame_example/lib/overlays/help_overlay.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gamepads_flame_example/game.dart'; -import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; class HelpOverlay extends StatelessWidget { final MyGame game; diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart similarity index 100% rename from packages/flutter_gamepads/flame_example/lib/overlays/overlays.dart rename to packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart similarity index 68% rename from packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart rename to packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart index 2cf49267a..fadd2c9b8 100644 --- a/packages/flutter_gamepads/flame_example/lib/overlays/statusbar.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gamepads_flame_example/game.dart'; -import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; -import 'package:flutter_gamepads_flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; class StatusBarOverlay extends StatefulWidget { final MyGame game; @@ -20,12 +19,25 @@ class _StatusBarOverlayState extends State { color: Theme.of(context).colorScheme.surface, child: Row( children: [ + if (widget.game.exitApp != null) ...[ + FilledButton( + onPressed: widget.game.exitApp, + child: const Icon( + Icons.chevron_left, + semanticLabel: 'Exit Flame Example', + ), + ), + const SizedBox(width: 5), + ], FilledButton( onPressed: onTogglePause, child: ValueListenableBuilder( valueListenable: widget.game.gameState.userPaused, builder: (context, userPaused, child) { - return Icon(userPaused ? Icons.play_arrow : Icons.pause); + return Icon( + userPaused ? Icons.play_arrow : Icons.pause, + semanticLabel: userPaused ? 'Unpause' : 'Pause', + ); }, ), ), @@ -37,7 +49,10 @@ class _StatusBarOverlayState extends State { builder: (context, value, child) { return Row( children: [ - Image.asset('assets/images/power_up.png'), + Image.asset( + 'assets/images/power_up.png', + semanticLabel: 'Power ups', + ), Text('$value'), ], ); diff --git a/packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart similarity index 91% rename from packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart rename to packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart index f19b3178e..7eed78bb9 100644 --- a/packages/flutter_gamepads/flame_example/lib/overlays/upgrade_overlay.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gamepads_flame_example/game.dart'; -import 'package:flutter_gamepads_flame_example/overlays/overlays.dart'; -import 'package:flutter_gamepads_flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; class UpgradeOverlay extends StatelessWidget { final MyGame game; diff --git a/packages/flutter_gamepads/flame_example/lib/state/game_state.dart b/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart similarity index 100% rename from packages/flutter_gamepads/flame_example/lib/state/game_state.dart rename to packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart diff --git a/packages/flutter_gamepads/flame_example/lib/world.dart b/packages/flutter_gamepads/example/lib/flame_example/world.dart similarity index 97% rename from packages/flutter_gamepads/flame_example/lib/world.dart rename to packages/flutter_gamepads/example/lib/flame_example/world.dart index 7dfc9a736..c555a1b9d 100644 --- a/packages/flutter_gamepads/flame_example/lib/world.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/world.dart @@ -5,8 +5,8 @@ import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gamepads_flame_example/game.dart'; -import 'package:flutter_gamepads_flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; import 'package:gamepads/gamepads.dart'; class MyWorld extends World diff --git a/packages/flutter_gamepads/example/lib/flutter_example/README.md b/packages/flutter_gamepads/example/lib/flutter_example/README.md new file mode 100644 index 000000000..53b21f776 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/README.md @@ -0,0 +1,7 @@ +# flutter_gamepads example + +A simple example project showcasing the flutter_gamepads plugin. + +All gamepad input in this app is provided via `flutter_gamepads` package. It uses `GamepadInterceptor` +in some places to to provide gamepad support for certain widgets or scenarios that does not +work out of the box with just wrapping the app with `GamepadControl`. diff --git a/packages/flutter_gamepads/example/lib/flutter_example/main.dart b/packages/flutter_gamepads/example/lib/flutter_example/main.dart new file mode 100644 index 000000000..8b771e59a --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/main.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/game_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/home_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/settings_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/theme.dart'; + +void main() { + runApp(const MyFlutterApp()); +} + +class MyFlutterApp extends StatelessWidget { + final void Function()? exitApp; + const MyFlutterApp({this.exitApp, super.key}); + + @override + Widget build(BuildContext context) { + return GamepadControl( + child: MaterialApp( + theme: appTheme(), + initialRoute: '/', + routes: { + '/': (context) => HomePage(exitApp: exitApp), + '/settings': (context) => const SettingsPage(), + '/game': (context) => const GamePage(), + }, + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/pages/game_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart similarity index 100% rename from packages/flutter_gamepads/example/lib/pages/game_page.dart rename to packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart diff --git a/packages/flutter_gamepads/example/lib/pages/home_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart similarity index 91% rename from packages/flutter_gamepads/example/lib/pages/home_page.dart rename to packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart index 5ee0fb36f..6bf45931c 100644 --- a/packages/flutter_gamepads/example/lib/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart @@ -3,7 +3,8 @@ import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:gamepads/gamepads.dart'; class HomePage extends StatelessWidget { - const HomePage({super.key}); + final void Function()? exitApp; + const HomePage({this.exitApp, super.key}); @override Widget build(BuildContext context) { @@ -18,22 +19,25 @@ class HomePage extends StatelessWidget { Align( alignment: AlignmentGeometry.centerRight, child: FilledButton( + autofocus: exitApp == null, onPressed: () => Navigator.of(context).pop(), child: const Icon(Icons.chevron_left, semanticLabel: 'Close'), ), ), - const SizedBox(height: 50), - FilledButton( - autofocus: true, - onPressed: () => onGotoSettings(context), - child: const Text('Settings'), - ), + if (exitApp != null) ...[ + const SizedBox(height: 50), + FilledButton( + autofocus: true, + onPressed: exitApp, + child: const Text('Exit Flutter Example'), + ), + ], ], ), ), ), appBar: AppBar( - title: const Text('Flutter Gamepads sample app'), + title: const Text('Flutter Example'), ), body: ListView( padding: const EdgeInsets.all(20), @@ -49,17 +53,17 @@ class HomePage extends StatelessWidget { onPressed: () => onShowGame(context), child: const Text('Play game'), ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onGotoSettings(context), + child: const Text('Settings'), + ), const SizedBox(height: 20), FilledButton( onPressed: () => onShowDialog(context), child: const Text('Show dialog'), ), const SizedBox(height: 20), - FilledButton( - onPressed: () => onShowSnackbar(context), - child: const Text('Show snackbar'), - ), - const SizedBox(height: 20), Card( child: Padding( padding: const EdgeInsets.all(20), @@ -164,7 +168,6 @@ class HomePage extends StatelessWidget { } void onGotoSettings(BuildContext context) { - Navigator.of(context).pop(); Navigator.of(context).pushNamed('/settings'); } } diff --git a/packages/flutter_gamepads/example/lib/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart similarity index 100% rename from packages/flutter_gamepads/example/lib/pages/settings_page.dart rename to packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart diff --git a/packages/flutter_gamepads/example/lib/theme.dart b/packages/flutter_gamepads/example/lib/flutter_example/theme.dart similarity index 100% rename from packages/flutter_gamepads/example/lib/theme.dart rename to packages/flutter_gamepads/example/lib/flutter_example/theme.dart diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index bea702003..2d7d7207c 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -1,29 +1,106 @@ import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; -import 'package:flutter_gamepads_example/pages/game_page.dart'; -import 'package:flutter_gamepads_example/pages/home_page.dart'; -import 'package:flutter_gamepads_example/pages/settings_page.dart'; -import 'package:flutter_gamepads_example/theme.dart'; +import 'package:flutter_gamepads_example/flame_example/main.dart'; +import 'package:flutter_gamepads_example/flutter_example/main.dart'; void main() { - runApp(const MyApp()); + runApp(const ChooserApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +enum Example { + flutter('A pure Flutter example'), + flame('A Flame game example'); + + const Example(this.description); + + final String description; +} + +class ChooserApp extends StatefulWidget { + const ChooserApp({super.key}); + + @override + State createState() => _ChooserAppState(); +} + +class _ChooserAppState extends State { + Example? example; @override Widget build(BuildContext context) { + return switch (example) { + Example.flame => MyFlameApp(exitApp: exitApp), + Example.flutter => MyFlutterApp(exitApp: exitApp), + null => buildSelectionUi(context), + }; + } + + Widget buildSelectionUi(BuildContext context) { return GamepadControl( + ignoreEvents: example != null, child: MaterialApp( - theme: appTheme(), - initialRoute: '/', - routes: { - '/': (context) => const HomePage(), - '/settings': (context) => const SettingsPage(), - '/game': (context) => const GamePage(), - }, + theme: _theme, + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Choose an Example app', + style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), + ), + ...Example.values + .map( + (ex) => Padding( + padding: const EdgeInsets.only(top: 20), + child: FilledButton( + onPressed: () { + setState(() => example = ex); + }, + child: Column( + children: [ + Text( + ex.name[0].toUpperCase() + ex.name.substring(1), + style: Theme.of(context).textTheme.titleMedium, + ), + Text(ex.description), + ], + ), + ), + ), + ) + .toList(), + ], + ), + ), + ), ), ); } + + void exitApp() { + setState(() { + example = null; + }); + } } + +ThemeData get _theme => ThemeData.from( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.dark, + ), +).copyWith( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + padding: const WidgetStatePropertyAll(EdgeInsets.all(30)), + side: WidgetStateProperty.resolveWith((state) { + return state.contains(WidgetState.focused) ? BorderSide( + color: Colors.deepOrange[800]!, + width: 5 , + strokeAlign: 3, + ) : BorderSide.none; + }), + ) + ) +); diff --git a/packages/flutter_gamepads/example/pubspec.yaml b/packages/flutter_gamepads/example/pubspec.yaml index 903beafca..1e6b8398d 100644 --- a/packages/flutter_gamepads/example/pubspec.yaml +++ b/packages/flutter_gamepads/example/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: collection: ^1.19.1 + flame: ^1.36.0 flutter: sdk: flutter flutter_gamepads: ^0.1.0 @@ -22,3 +23,7 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/images/spaceship.png + - assets/images/power_up.png \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/.gitignore b/packages/flutter_gamepads/flame_example/.gitignore deleted file mode 100644 index 092376903..000000000 --- a/packages/flutter_gamepads/flame_example/.gitignore +++ /dev/null @@ -1,53 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - - -/test -/macos -/android -/ios -/web -/windows -/linux \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/.metadata b/packages/flutter_gamepads/flame_example/.metadata deleted file mode 100644 index f0bb85c04..000000000 --- a/packages/flutter_gamepads/flame_example/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" - channel: "[user-branch]" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - - platform: windows - create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flutter_gamepads/flame_example/analysis_options.yaml b/packages/flutter_gamepads/flame_example/analysis_options.yaml deleted file mode 100644 index ba5631f3b..000000000 --- a/packages/flutter_gamepads/flame_example/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/flame_example/pubspec.yaml b/packages/flutter_gamepads/flame_example/pubspec.yaml deleted file mode 100644 index 9f9f3e4e6..000000000 --- a/packages/flutter_gamepads/flame_example/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: flutter_gamepads_flame_example -description: A simple game example project showcasing the flutter_gamepads plugin. -resolution: workspace -publish_to: 'none' - -version: 0.1.0 - -environment: - sdk: ">=3.9.0 <4.0.0" - -dependencies: - flame: ^1.36.0 - flutter: - sdk: flutter - flutter_gamepads: ^0.1.0 - gamepads: ^0.1.10 - -dev_dependencies: - flame_lint: ^1.4.1 - flutter_test: - sdk: flutter - -flutter: - uses-material-design: true - - assets: - - assets/images/spaceship.png - - assets/images/power_up.png \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index dfc88308c..8dee05e93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,6 @@ repository: https://github.com/flame-engine/gamepads workspace: - packages/flutter_gamepads - packages/flutter_gamepads/example - - packages/flutter_gamepads/flame_example - packages/gamepads - packages/gamepads/example - packages/gamepads_android From 9f54e838cb36a810a7cfb4f7d90c4c6c7b9db4b1 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 19:52:20 +0200 Subject: [PATCH 18/65] fix: Cancel timers in GamepadControl in dispose() --- packages/flutter_gamepads/lib/src/gamepad_control.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index 7e9849758..dda19d2d3 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -89,6 +89,10 @@ class _GamepadControlState extends State { @override void dispose() { _subscription?.cancel(); + _repeat.values.forEach((timer) { + timer.cancel(); + }); + _repeat.clear(); super.dispose(); } From 975927e037c2c7abab6c5556a018cb490e043e50 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 19:59:59 +0200 Subject: [PATCH 19/65] fix: Cancel timers also on ignoreEvents=true --- .../lib/src/gamepad_control.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index dda19d2d3..b86fb9514 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -25,13 +25,18 @@ class GamepadControl extends StatefulWidget { /// Called just before an Intent is invoked. Return false to block /// emitting the Intent. /// + /// This includes both initial activation of a button or axis, but + /// also repetition due to holding long enough for input repeat. + /// /// Additionally, you can wrap something deep in your tree with /// [GamepadInterceptor] widget to receive onBeforeIntent locally /// in that build context. this.onBeforeIntent, - /// If set to true, the gamepad control is temporarily disabled. It still - /// listen on the gamepad, but just ignores the events. + /// If set to true, the gamepad control is temporarily disabled. All + /// input repeats gets canceled and to activate axis inputs users + /// has to go from non-active to active axis value again (after + /// ignoreEvents has become true again). this.ignoreEvents = false, /// Configures the bindings between Gamepad activator (button or axis) @@ -89,10 +94,7 @@ class _GamepadControlState extends State { @override void dispose() { _subscription?.cancel(); - _repeat.values.forEach((timer) { - timer.cancel(); - }); - _repeat.clear(); + _cancelAllRepeatTimers(); super.dispose(); } @@ -101,6 +103,7 @@ class _GamepadControlState extends State { super.didUpdateWidget(oldWidget); if (widget.ignoreEvents) { _previousAxisValue.clear(); + _cancelAllRepeatTimers(); } } @@ -218,4 +221,11 @@ class _GamepadControlState extends State { _previousAxisValue[event.axis!] = event.value; } } + + void _cancelAllRepeatTimers() { + _repeat.values.forEach((timer) { + timer.cancel(); + }); + _repeat.clear(); + } } From 475486376a0dcc50765c69e7371b25119ba86289 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 20:38:55 +0200 Subject: [PATCH 20/65] fix: Trap focus in dialogs without focusing a button When mouse user is using the app, we don't want to focus a button in the dialog directly. --- .../flame_example/overlays/help_overlay.dart | 34 ++++++++-------- .../overlays/overlay_dialog_backdrop.dart | 40 +++++++++++++++++++ .../overlays/upgrade_overlay.dart | 4 +- 3 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart index dfc73ad28..1ba0ee260 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlay_dialog_backdrop.dart'; import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; class HelpOverlay extends StatelessWidget { @@ -8,27 +9,12 @@ class HelpOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusScope( - canRequestFocus: true, - autofocus: true, + return OverlayDialogBackdrop( child: AlertDialog( title: const Text('Controls'), - content: const Text('''** Spaceship ** -Turn left: A, LeftArrow, Gamepad left stick -Turn right: D, RightArrow, Gamepad left stick -Accelerate: W, UpArrow, Gamepad right trigger -Brake: D, DownArrow, Gamepad left trigger - -Note: to brake, you need the turnRail upgrade. - -** Gamepad UI controls ** -Move focus: D-pad -Activate button: A -Close dialog: B -'''), + content: const Text(_bodyText), actions: [ FilledButton( - autofocus: true, onPressed: () { game.hideOverlay(MyOverlays.help); }, @@ -39,3 +25,17 @@ Close dialog: B ); } } + +const _bodyText = '''** Spaceship ** +Turn left: A, LeftArrow, Gamepad left stick +Turn right: D, RightArrow, Gamepad left stick +Accelerate: W, UpArrow, Gamepad right trigger +Brake: D, DownArrow, Gamepad left trigger + +Note: to brake, you need the turnRail upgrade. + +** Gamepad UI controls ** +Move focus: D-pad +Activate button: A +Close dialog: B + '''; diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart new file mode 100644 index 000000000..a29798efd --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; + +/// A dialog backdrop that also moves Focus to within the dialog and blocks +/// pointer input outside of the dialog. +class OverlayDialogBackdrop extends StatefulWidget { + final Widget child; + const OverlayDialogBackdrop({required this.child, super.key}); + + @override + State createState() => _OverlayDialogBackdropState(); +} + +class _OverlayDialogBackdropState extends State { + final FocusScopeNode _focusScope = FocusScopeNode(); + + @override + void initState() { + super.initState(); + // Set the focus within the dialog just after it opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusScope.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // A FocusScope widget traps the focus to stay within the dialog. + // (but it doesn't itself move the focus there, hence the code in + // initState) + child: FocusScope(node: _focusScope, child: widget.child), + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart index 7eed78bb9..aa6a0c95c 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlay_dialog_backdrop.dart'; import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; @@ -9,7 +10,7 @@ class UpgradeOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusScope( + return OverlayDialogBackdrop( child: AlertDialog( title: const Text('Upgrades'), content: SizedBox( @@ -35,7 +36,6 @@ class UpgradeOverlay extends StatelessWidget { ), actions: [ FilledButton( - autofocus: true, onPressed: () { game.hideOverlay(MyOverlays.upgrade); }, From 5daa77e6293c8f667fc1177b0d15e34502d9f53b Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 21:01:07 +0200 Subject: [PATCH 21/65] docs: Add Flame specific guidance to README --- packages/flutter_gamepads/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 5fdb73496..5f9b67320 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -149,3 +149,24 @@ Use onBeforeIntent to catch eg. the ScrollIntent and use that to implement inter with your widget. Then test and repeat. + + +### Flame specific guidance + +`flutter_gamepad` can be helpful in scenarios when you have overlays in your +Flame game that you want users to be able to navigate with their gamepad. + +1. Wrap your `GameWidget` with a `GamepadControl` widget +2. For overlays that represent a modal dialog, you will need to trap the focus + in the dialog. See + [OverlayDialogBackdrop](example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) + in Flame example app for how you can do that. In that example the dialog itself + will receive the focus so that when a mouse user opens the dialog, it won't show + a focus indicator on a button in the dialog. +3. To close overlay dialogs on DismissIntent, you will need to catch it with + `onBeforeIntent` and close the overlay. In + [Flame Example](example/lib/flame_example/main.dart) this is done generically + at the root, but could instead wrap each dialog in a `GamepadInterceptor` to + do it locally if you need to guard closing the dialog by some condition. +4. If you need to disable `GamepadControl` while in-game you can do so by setting + `ignoreEvents = true` on it. \ No newline at end of file From 480c19ad59ce5ae02fa417a960134be5198daa51 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 21:11:58 +0200 Subject: [PATCH 22/65] docs: Add note to note have multiple active GamepadControl in tree at the same time --- packages/flutter_gamepads/lib/src/gamepad_control.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index b86fb9514..5d16ca56e 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -7,8 +7,13 @@ import 'package:gamepads/gamepads.dart'; /// Wrap your widget tree with this widget to allow users /// to navigate it using their gamepad. /// -/// Make sure your theme is setup so that widgets get a clear -/// visual indicator when they are focused. +/// Note: If you have more than one concurrent GamepadControl +/// in your widget tree, you need to set ignoreEvents=true on +/// at least n-1 of your GamepadControl widgets to avoid duplicate +/// intents being fired. +/// +/// Make sure your theme is setup so that widgets get a clearly +/// visible visual indicator when they are focused. class GamepadControl extends StatefulWidget { final Widget child; final bool ignoreEvents; From fd0e2ecc518374439a659628fcde8648a63ccfc6 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 21:32:54 +0200 Subject: [PATCH 23/65] refactor: Extract SliderWithGamepadSupport widget Also reduce the Flutter example a bit. --- .../flutter_example/pages/settings_page.dart | 82 +++---------------- .../pages/slider_with_gamepad_support.dart | 54 ++++++++++++ .../flutter_gamepads/example/lib/main.dart | 82 ++++++++++--------- 3 files changed, 108 insertions(+), 110 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart index 0585294b2..1c557705b 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/slider_with_gamepad_support.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @@ -14,8 +15,6 @@ class _SettingsPageState extends State { final TextEditingController nameController = TextEditingController( text: 'Player One', ); - FocusNode volumeFocusNode = FocusNode(); - final volumeHasFocus = ValueNotifier(false); double volume = 50; String selectedGenre = 'Adventure'; bool vibrationEnabled = true; @@ -28,7 +27,6 @@ class _SettingsPageState extends State { @override void dispose() { nameController.dispose(); - volumeFocusNode.dispose(); super.dispose(); } @@ -47,31 +45,18 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 20), - GamepadInterceptor( - onBeforeIntent: (activator, intent) { - // The Slider widget does not itself support any public Intent to - // control it. - // - // So instead we intercept that GamepadControl is about to emit a - // ScrollIntent and implement changing the Slider value ourself. - if (intent is ScrollIntent) { - if (intent.direction == AxisDirection.right) { - setState(() => volume = min(100, volume + 10)); - } else if (intent.direction == AxisDirection.left) { - setState(() => volume = max(0, volume - 10)); - } - return false; - } - return true; + // This one Wraps a Slider with GamepadInterceptor to add gamepad + // support to the default Slider widget + SliderWithGamepadSupport( + value: volume, + max: 100, + divisions: 5, + label: 'Volume: ${volume.round()}', + onChanged: (value) { + setState(() { + volume = value; + }); }, - child: Slider.adaptive( - focusNode: volumeFocusNode, - max: 100, - divisions: 10, - label: 'Volume: ${volume.round()}', - value: volume, - onChanged: (value) => setState(() => volume = value), - ), ), const SizedBox(height: 20), ListTile( @@ -107,51 +92,8 @@ class _SettingsPageState extends State { value: vibrationEnabled, onChanged: (value) => setState(() => vibrationEnabled = value), ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: FilledButton( - style: Theme.of(context).filledButtonTheme.style!.copyWith( - backgroundColor: WidgetStatePropertyAll(Colors.grey[600]), - ), - onPressed: onReset, - child: const Text('Reset settings'), - ), - ), - const SizedBox(width: 20), - Expanded( - child: FilledButton( - onPressed: () => onSave(context), - child: const Text('Save settings'), - ), - ), - ], - ), ], ), ); } - - void onReset() { - setState(() { - nameController.text = 'Player One'; - volume = 50; - selectedGenre = 'Adventure'; - vibrationEnabled = true; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Settings reset')), - ); - } - - void onSave(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Saved ${nameController.text} / $selectedGenre / ${volume.round()}', - ), - ), - ); - } } diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart new file mode 100644 index 000000000..81fcc7ccc --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart @@ -0,0 +1,54 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; + +class SliderWithGamepadSupport extends StatelessWidget { + final int divisions; + final double value; + final String? label; + final double min; + final double max; + final void Function(double) onChanged; + + const SliderWithGamepadSupport({ + required this.onChanged, + required this.value, + required this.divisions, + this.min = 0, + this.max = 1.0, + this.label, + super.key, + }); + + double get step => (max - min) / math.max(divisions, 1); + + @override + Widget build(BuildContext context) { + return GamepadInterceptor( + onBeforeIntent: (activator, intent) { + // The Slider widget does not itself support any public Intent to + // control it. + // + // So instead we intercept that GamepadControl is about to emit a + // ScrollIntent and implement changing the Slider value ourself. + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.right) { + onChanged(math.min(max, value + step)); + } else if (intent.direction == AxisDirection.left) { + onChanged(math.max(min, value - step)); + } + return false; + } + return true; + }, + child: Slider.adaptive( + max: max, + divisions: divisions, + label: label, + value: value, + onChanged: onChanged, + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 2d7d7207c..7f1b06fb5 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -37,7 +37,6 @@ class _ChooserAppState extends State { Widget buildSelectionUi(BuildContext context) { return GamepadControl( - ignoreEvents: example != null, child: MaterialApp( theme: _theme, home: Scaffold( @@ -47,29 +46,29 @@ class _ChooserAppState extends State { children: [ Text( 'Choose an Example app', - style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), + style: Theme.of( + context, + ).textTheme.titleLarge!.copyWith(color: Colors.white), ), - ...Example.values - .map( - (ex) => Padding( - padding: const EdgeInsets.only(top: 20), - child: FilledButton( - onPressed: () { - setState(() => example = ex); - }, - child: Column( - children: [ - Text( - ex.name[0].toUpperCase() + ex.name.substring(1), - style: Theme.of(context).textTheme.titleMedium, - ), - Text(ex.description), - ], + ...Example.values.map( + (ex) => Padding( + padding: const EdgeInsets.only(top: 20), + child: FilledButton( + onPressed: () { + setState(() => example = ex); + }, + child: Column( + children: [ + Text( + ex.name[0].toUpperCase() + ex.name.substring(1), + style: Theme.of(context).textTheme.titleMedium, ), - ), + Text(ex.description), + ], ), - ) - .toList(), + ), + ), + ), ], ), ), @@ -85,22 +84,25 @@ class _ChooserAppState extends State { } } -ThemeData get _theme => ThemeData.from( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.orange, - brightness: Brightness.dark, - ), -).copyWith( - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll(EdgeInsets.all(30)), - side: WidgetStateProperty.resolveWith((state) { - return state.contains(WidgetState.focused) ? BorderSide( - color: Colors.deepOrange[800]!, - width: 5 , - strokeAlign: 3, - ) : BorderSide.none; - }), - ) - ) -); +ThemeData get _theme => + ThemeData.from( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.dark, + ), + ).copyWith( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + padding: const WidgetStatePropertyAll(EdgeInsets.all(30)), + side: WidgetStateProperty.resolveWith((state) { + return state.contains(WidgetState.focused) + ? BorderSide( + color: Colors.deepOrange[800]!, + width: 5, + strokeAlign: 3, + ) + : BorderSide.none; + }), + ), + ), + ); From a68e2c6e728e062843bd6f08d5c167b7453b5ec1 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 21:44:40 +0200 Subject: [PATCH 24/65] docs: provide more in-app guidance in Flutter example --- .../lib/flutter_example/pages/game_page.dart | 23 ++++++++++++++++++ .../lib/flutter_example/pages/home_page.dart | 24 +++++++++++++------ .../flutter_example/pages/settings_page.dart | 10 ++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart index e360e7642..f57066a36 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; @@ -98,6 +100,27 @@ class _TickTackToeState extends State<_TickTackToe> { color: Colors.white70, ), ), + const SizedBox(height: 20), + Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.sizeOf(context).width, 500), + ), + child: const Text( + 'GAMEPAD INFO\n' + 'You can use the D-pad or right stick to move focus directionally up/down/left/right' + ' which is supported via GamepadInterceptor.' + '\n\n' + 'Left stick only works while focus is within the 3x3 grid.', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.white70, + ), + softWrap: true, + textAlign: TextAlign.center, + ), + ), + ), ], ), ); diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart index 6bf45931c..60d7e8127 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:gamepads/gamepads.dart'; @@ -54,10 +56,10 @@ class HomePage extends StatelessWidget { child: const Text('Play game'), ), const SizedBox(height: 20), - FilledButton( - onPressed: () => onGotoSettings(context), - child: const Text('Settings'), - ), + FilledButton( + onPressed: () => onGotoSettings(context), + child: const Text('Settings'), + ), const SizedBox(height: 20), FilledButton( onPressed: () => onShowDialog(context), @@ -131,9 +133,9 @@ class HomePage extends StatelessWidget { // ListView. if (intent is ScrollIntent) { if (intent.direction == AxisDirection.up) { - controller.jumpTo(controller.offset - 100); + controller.jumpTo(max(0, controller.offset - 75)); } else if (intent.direction == AxisDirection.down) { - controller.jumpTo(controller.offset + 100.0); + controller.jumpTo(min(controller.position.maxScrollExtent, controller.offset + 75.0)); } return false; } @@ -146,7 +148,15 @@ class HomePage extends StatelessWidget { width: 200, child: ListView( controller: controller, - children: GamepadButton.values.map((b) => Text(b.name)).toList(), + children: [ + const Text( + 'You can use right stick on gamepad to scroll this view.' + ' It is supported via GamepadInterceptor.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + SizedBox(height: 20), + ...GamepadButton.values.map((b) => Text(b.name)), + ], ), ), actions: [ diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart index 1c557705b..32558d7d1 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart @@ -44,6 +44,11 @@ class _SettingsPageState extends State { prefixIcon: Icon(Icons.person_outline), ), ), + const Text( + 'It is not possible to enter text with Gamepad', + style: TextStyle(fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ), const SizedBox(height: 20), // This one Wraps a Slider with GamepadInterceptor to add gamepad // support to the default Slider widget @@ -58,6 +63,11 @@ class _SettingsPageState extends State { }); }, ), + const Text( + 'The slider can be changed (while it is focused) using right stick on your gamepad. Supported via GamepadInterceptor.', + style: TextStyle(fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ), const SizedBox(height: 20), ListTile( title: const Text('Genre'), From 2e2c5c964095c033053de440e5b4920ba0c51d66 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 21:56:38 +0200 Subject: [PATCH 25/65] docs: Refer to SliderWithGamepadSupport from README --- packages/flutter_gamepads/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 5f9b67320..795bae3b5 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -74,6 +74,9 @@ GamepadInterceptor( Note that `GamepadInterceptor` must be placed below the `GamepadControl` widget in the widget tree. +An example of how to package an Gamepad-extended widget can be found in +[SliderWithGamepadExport](example/lib/flutter_example/pages/slider_with_gamepad_support.dart). + ### Changing the Gamepad bindings From 740fafe3e75f37e078faf5dd1438854361a1f1c7 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 22:39:17 +0200 Subject: [PATCH 26/65] feat: list of repeatable intents --- .../lib/src/api/gamepad_activator.dart | 2 + .../lib/src/gamepad_control.dart | 54 +++++++++++++++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart b/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart index 7281a5657..e4ca35899 100644 --- a/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart +++ b/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:gamepads/gamepads.dart'; +@immutable class GamepadActivator { const GamepadActivator(); } diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart index 5d16ca56e..03d621a81 100644 --- a/packages/flutter_gamepads/lib/src/gamepad_control.dart +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -21,6 +21,7 @@ class GamepadControl extends StatefulWidget { final Map shortcuts; + final Set repeatIntents; final Duration initialRepeatDelay; final Duration repeatedRepeatDelay; @@ -71,6 +72,22 @@ class GamepadControl extends StatefulWidget { ), }, + /// Set of intents which will be repeated if a GamepadActivator + /// linked to this Intent is being activated for + /// [initialRepeatDelay]. After that [repeatedRepeatDelay] sets + /// the delay between each repeat. + /// + /// Intents not listed here will not have input repetition on + /// hold. + this.repeatIntents = const { + PreviousFocusIntent(), + NextFocusIntent(), + ScrollIntent(direction: AxisDirection.up), + ScrollIntent(direction: AxisDirection.down), + ScrollIntent(direction: AxisDirection.left), + ScrollIntent(direction: AxisDirection.right), + }, + /// Delay after first input until first input repeat occurs. this.initialRepeatDelay = const Duration(milliseconds: 700), @@ -128,7 +145,11 @@ class _GamepadControlState extends State { _repeat.remove(intent); } if (activated) { - _maybeInvokeIntent(activator, intent, const Duration(milliseconds: 700)); + _maybeInvokeIntent( + activator, + intent, + const Duration(milliseconds: 700), + ); } } _updatePreviousAxisValues(event); @@ -137,14 +158,21 @@ class _GamepadControlState extends State { /// Find intents that match the given gamepad event. /// /// Return list of (Intent, activated, canceled) - List<(GamepadActivator, Intent, bool, bool)> _find(NormalizedGamepadEvent event) { + List<(GamepadActivator, Intent, bool, bool)> _find( + NormalizedGamepadEvent event, + ) { final result = <(GamepadActivator, Intent, bool, bool)>[]; for (final entry in widget.shortcuts.entries) { final activator = entry.key; switch (activator) { case final GamepadActivatorButton buttonActivator: if (buttonActivator.button == event.button) { - result.add((activator, entry.value, event.value != 0, event.value == 0)); + result.add(( + activator, + entry.value, + event.value != 0, + event.value == 0, + )); } case final GamepadActivatorAxis axisActivator: if (axisActivator.axis == event.axis) { @@ -174,15 +202,19 @@ class _GamepadControlState extends State { /// schedule a repeat after [repeatDuration]. void _maybeInvokeIntent( GamepadActivator activator, - Intent intent, Duration repeatDuration) { + Intent intent, + Duration repeatDuration, + ) { final activateContext = _resolveInvokeContext(intent); if (activateContext != null) { // Activate the timer before calling _allowInvoke so that interceptors // receive repeated input. - _repeat[intent] = Timer( - repeatDuration, - () => _onRepeat(activator, intent), - ); + if (widget.repeatIntents.contains(intent)) { + _repeat[intent] = Timer( + repeatDuration, + () => _onRepeat(activator, intent), + ); + } final allowInvoke = _allowInvoke(activateContext, activator, intent); if (allowInvoke) { Actions.maybeInvoke(activateContext, intent); @@ -204,7 +236,11 @@ class _GamepadControlState extends State { /// Check if invoking [intent] is permitted by /// CallbackInterceptor.onBeforeInvoke and onBeforeInvoke. - bool _allowInvoke(BuildContext activateContext, GamepadActivator activator, Intent intent) { + bool _allowInvoke( + BuildContext activateContext, + GamepadActivator activator, + Intent intent, + ) { final interceptor = activateContext .findAncestorWidgetOfExactType(); var allow = true; From 02c9b6863f04fabe31b700fda9a6955f8e24cedc Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 22:51:18 +0200 Subject: [PATCH 27/65] docs: update README.md --- packages/flutter_gamepads/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 795bae3b5..2f417cb49 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -7,7 +7,7 @@ A Flutter plugin to handle gamepad input across multiple platforms. Using just `GamepadControl` out-of-the-box allow users to change focus around your app similar to using the Tab key, but using the D-pad buttons on their gamepad. Several -'simple' widgets like Buttons, DropdownMenu, Switch etc. just works. +'simple' widgets like buttons, DropdownMenu, Switch etc. just works. Some more complex interactive widgets like eg. the Slider widget needs some special attention to support. This package provides a `GamepadInterceptor` widget that you can use to handle @@ -31,8 +31,8 @@ those situations. ### GamepadControl -Wrap your widgets with GamepadControl to allow users to navigate it using their gamepad. It is -recommended to at any given time only have one GamepadControl in your widget tree. +Wrap your widgets with `GamepadControl` to allow users to navigate it using their gamepad. It is +recommended to at any given time only have one `GamepadControl` in your widget tree. ```dart GamepadControl( @@ -44,7 +44,7 @@ GamepadControl( ### GamepadInterceptor If you want to intercept a Gamepad intent locally next to a Widget you can do so with -`GamepadInterceptor`. Its onBeforeIntent is only called if a descendant widget has focus. +`GamepadInterceptor`. Its `onBeforeIntent` is only called if a descendant widget has focus. ```dart GamepadInterceptor( @@ -80,8 +80,9 @@ An example of how to package an Gamepad-extended widget can be found in ### Changing the Gamepad bindings -You can customize the default gamepad bindings by providing a map between GamepadActivator -and any Intent. +You can customize the default gamepad bindings by providing a map between `GamepadActivator` +and any `Intent`. Flutter comes with a set of generally supported intents, but you can also +pass in your custom Intents as long as you provide Actions for them. ```dart GamepadControl( @@ -107,12 +108,12 @@ GamepadControl( ### Temporary disabling Gamepad input -From onBeforeIntent in a GamepadInterceptor or the GamepadControl widget you can return +From `onBeforeIntent` in a `GamepadInterceptor` or the `GamepadControl` widget you can return false to block an intent from being emitted. -On GamepadControl you may also set ignoreEvents = true to an an earlier level temporarily -block all Gamepad input processing. When ignoreEvents is reset to false, all axis input -is reset to default (non-activated) state. +On `GamepadControl` you may also set `ignoreEvents = true` to an an earlier level temporarily +block all Gamepad input processing. Setting `ignoreEvents` to true does clear currently activated +axes and resets repeats, while `onBeforeIntent` just block each intent from being emitted. ## Implementation strategy recommendation @@ -148,7 +149,7 @@ GamepadControl( Then, wrap those problematic widgets with `GamepadInterceptor` and ensure that the widget itself can receive focus. -Use onBeforeIntent to catch eg. the ScrollIntent and use that to implement interaction +Use `onBeforeIntent` to catch eg. the `ScrollIntent` and use that to implement interaction with your widget. Then test and repeat. From 3eb40ca44946bfddabcca09171083a468a6b9909 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 22:58:28 +0200 Subject: [PATCH 28/65] docs: update links --- packages/flutter_gamepads/README.md | 11 ++++++----- packages/flutter_gamepads/example/README.md | 7 ++++--- .../example/lib/flame_example/README.md | 4 +--- .../example/lib/flutter_example/README.md | 4 +--- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 2f417cb49..77dcaf27c 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -75,7 +75,7 @@ Note that `GamepadInterceptor` must be placed below the `GamepadControl` widget widget tree. An example of how to package an Gamepad-extended widget can be found in -[SliderWithGamepadExport](example/lib/flutter_example/pages/slider_with_gamepad_support.dart). +[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). ### Changing the Gamepad bindings @@ -163,14 +163,15 @@ Flame game that you want users to be able to navigate with their gamepad. 1. Wrap your `GameWidget` with a `GamepadControl` widget 2. For overlays that represent a modal dialog, you will need to trap the focus in the dialog. See - [OverlayDialogBackdrop](example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) + [OverlayDialogBackdrop](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) in Flame example app for how you can do that. In that example the dialog itself will receive the focus so that when a mouse user opens the dialog, it won't show a focus indicator on a button in the dialog. 3. To close overlay dialogs on DismissIntent, you will need to catch it with `onBeforeIntent` and close the overlay. In - [Flame Example](example/lib/flame_example/main.dart) this is done generically - at the root, but could instead wrap each dialog in a `GamepadInterceptor` to - do it locally if you need to guard closing the dialog by some condition. + [Flame Example](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flame_example/main.dart) + this is done generically at the root, but could instead wrap each dialog in a + `GamepadInterceptor` to do it locally if you need to guard closing the dialog + by some condition. 4. If you need to disable `GamepadControl` while in-game you can do so by setting `ignoreEvents = true` on it. \ No newline at end of file diff --git a/packages/flutter_gamepads/example/README.md b/packages/flutter_gamepads/example/README.md index 53b21f776..657aec1e1 100644 --- a/packages/flutter_gamepads/example/README.md +++ b/packages/flutter_gamepads/example/README.md @@ -2,6 +2,7 @@ A simple example project showcasing the flutter_gamepads plugin. -All gamepad input in this app is provided via `flutter_gamepads` package. It uses `GamepadInterceptor` -in some places to to provide gamepad support for certain widgets or scenarios that does not -work out of the box with just wrapping the app with `GamepadControl`. +The example project consists of two distinct different apps: + +* [A Flutter example](lib/flutter_example/) +* [A Flame game example](lib/flame_example/) diff --git a/packages/flutter_gamepads/example/lib/flame_example/README.md b/packages/flutter_gamepads/example/lib/flame_example/README.md index b28d1f03a..f61a17e72 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/README.md +++ b/packages/flutter_gamepads/example/lib/flame_example/README.md @@ -1,6 +1,4 @@ -# flutter_gamepads flame_example - -A simple flame game example project showcasing the flutter_gamepads plugin. +# Flame example `flutter_gamepads` package is used to allow gamepad control over the overlays in the game. While the spaceship is controlled with `gamepads` events directly. diff --git a/packages/flutter_gamepads/example/lib/flutter_example/README.md b/packages/flutter_gamepads/example/lib/flutter_example/README.md index 53b21f776..8ea324f79 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/README.md +++ b/packages/flutter_gamepads/example/lib/flutter_example/README.md @@ -1,6 +1,4 @@ -# flutter_gamepads example - -A simple example project showcasing the flutter_gamepads plugin. +# Flame example All gamepad input in this app is provided via `flutter_gamepads` package. It uses `GamepadInterceptor` in some places to to provide gamepad support for certain widgets or scenarios that does not From 058641146d0371e567d3e07fcd9bb9bf74b58820 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 23:06:44 +0200 Subject: [PATCH 29/65] chore: dart analyze/fix/format --- .../example/lib/flame_example/game.dart | 5 +---- .../example/lib/flame_example/main.dart | 2 +- .../lib/flame_example/overlays/overlays.dart | 3 +-- .../lib/flame_example/overlays/statusbar.dart | 2 +- .../lib/flame_example/state/game_state.dart | 3 +-- .../example/lib/flame_example/world.dart | 15 +++------------ .../lib/flutter_example/pages/game_page.dart | 5 ++--- .../lib/flutter_example/pages/home_page.dart | 9 +++++++-- .../lib/flutter_example/pages/settings_page.dart | 6 ++---- 9 files changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/flame_example/game.dart b/packages/flutter_gamepads/example/lib/flame_example/game.dart index c74675fef..0f11db9d1 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/game.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/game.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flame/game.dart'; import 'package:flame/input.dart'; import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; @@ -17,8 +15,7 @@ class MyGame extends FlameGame with HasKeyboardHandlerComponents { overlays.activeOverlays.contains(MyOverlays.upgrade.name); void updateEnginePause() { - final shouldBePaused = - gameState.userPaused.value || anyDialogOpen; + final shouldBePaused = gameState.userPaused.value || anyDialogOpen; if (shouldBePaused != paused) { if (shouldBePaused) { pauseEngine(); diff --git a/packages/flutter_gamepads/example/lib/flame_example/main.dart b/packages/flutter_gamepads/example/lib/flame_example/main.dart index 97f968148..5586b6892 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/main.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/main.dart @@ -23,7 +23,7 @@ class MyFlameApp extends StatelessWidget { ThemeData.from( colorScheme: ColorScheme.dark( primary: Colors.orange[700]!, - surface: Color.lerp( Colors.orange[900], Colors.grey[800], 0.7)!, + surface: Color.lerp(Colors.orange[900], Colors.grey[800], 0.7)!, ), ).copyWith( dialogTheme: DialogThemeData( diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart index f7e04fa0b..b5a9098fd 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart @@ -1,6 +1,5 @@ - enum MyOverlays { help, statusbar, upgrade, -} \ No newline at end of file +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart index fadd2c9b8..b1a62494f 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart @@ -62,7 +62,7 @@ class _StatusBarOverlayState extends State { const SizedBox(width: 5), FilledButton( onPressed: onShowHelpOverlay, - child: Text('Controls'), + child: const Text('Controls'), ), ], ), diff --git a/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart b/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart index f2eda7d81..63fff6239 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart @@ -1,4 +1,3 @@ - import 'package:flame/components.dart'; import 'package:flutter/foundation.dart'; @@ -11,4 +10,4 @@ class GameState extends Component { final powerUps = ValueNotifier(0); final installedUpgrades = ValueNotifier>({}); final userPaused = ValueNotifier(false); -} \ No newline at end of file +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/world.dart b/packages/flutter_gamepads/example/lib/flame_example/world.dart index c555a1b9d..778ad5977 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/world.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/world.dart @@ -71,10 +71,10 @@ class SpaceShip extends SpriteComponent } void _basicUpdate(double dt) { - bool hasTurnRail = game.gameState.installedUpgrades.value.contains( + final hasTurnRail = game.gameState.installedUpgrades.value.contains( SpaceshipUpgrades.turnRail, ); - bool hasAutoBrake = game.gameState.installedUpgrades.value.contains( + final hasAutoBrake = game.gameState.installedUpgrades.value.contains( SpaceshipUpgrades.autoBrake, ); @@ -105,16 +105,6 @@ class SpaceShip extends SpriteComponent } } - void _turnRailUpdate(double dt) { - angle += inputX * dt * rotationVelocity; - dy = max(0, dy + inputY * dt * (inputY > 0 ? accel : decel)); - - if (dy.abs() > 0.001) { - position.x += cos(angle - pi / 2) * dy * dt; - position.y += sin(angle - pi / 2) * dy * dt; - } - } - @override bool onKeyEvent(KeyEvent event, Set keysPressed) { if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || @@ -163,6 +153,7 @@ class SpaceShip extends SpriteComponent } class PowerUp extends SpriteComponent { + @override Future onLoad() async { sprite = await Sprite.load('power_up.png'); anchor = Anchor.center; diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart index f57066a36..dbebe9cbe 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart @@ -19,7 +19,7 @@ class GamePage extends StatelessWidget { } class _TickTackToe extends StatefulWidget { - const _TickTackToe({super.key}); + const _TickTackToe(); @override State<_TickTackToe> createState() => _TickTackToeState(); @@ -182,7 +182,6 @@ class _Cell extends StatefulWidget { required this.index, required this.focusNode, required this.onActivate, - super.key, }); @override @@ -200,7 +199,7 @@ class _CellState extends State<_Cell> { Size(_cellSize, _cellSize), ), shape: const WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.zero), + RoundedRectangleBorder(), ), backgroundColor: WidgetStatePropertyAll(Colors.brown[300]), side: WidgetStateProperty.resolveWith((state) { diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart index 60d7e8127..8aa879715 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart @@ -135,7 +135,12 @@ class HomePage extends StatelessWidget { if (intent.direction == AxisDirection.up) { controller.jumpTo(max(0, controller.offset - 75)); } else if (intent.direction == AxisDirection.down) { - controller.jumpTo(min(controller.position.maxScrollExtent, controller.offset + 75.0)); + controller.jumpTo( + min( + controller.position.maxScrollExtent, + controller.offset + 75.0, + ), + ); } return false; } @@ -154,7 +159,7 @@ class HomePage extends StatelessWidget { ' It is supported via GamepadInterceptor.', style: TextStyle(fontStyle: FontStyle.italic), ), - SizedBox(height: 20), + const SizedBox(height: 20), ...GamepadButton.values.map((b) => Text(b.name)), ], ), diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart index 32558d7d1..458294f64 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart @@ -1,7 +1,4 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; -import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:flutter_gamepads_example/flutter_example/pages/slider_with_gamepad_support.dart'; class SettingsPage extends StatefulWidget { @@ -64,7 +61,8 @@ class _SettingsPageState extends State { }, ), const Text( - 'The slider can be changed (while it is focused) using right stick on your gamepad. Supported via GamepadInterceptor.', + 'The slider can be changed (while it is focused) using right stick ' + 'on your gamepad. Supported via GamepadInterceptor.', style: TextStyle(fontStyle: FontStyle.italic), textAlign: TextAlign.center, ), From f615e280067ceb17d78a5a51ff8a14eca7df94fa Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 23:13:44 +0200 Subject: [PATCH 30/65] fix: downgrade Flame for melos package constraints --- packages/flutter_gamepads/example/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/example/pubspec.yaml b/packages/flutter_gamepads/example/pubspec.yaml index 1e6b8398d..87d9de3c0 100644 --- a/packages/flutter_gamepads/example/pubspec.yaml +++ b/packages/flutter_gamepads/example/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: collection: ^1.19.1 - flame: ^1.36.0 + flame: ^1.32.0 flutter: sdk: flutter flutter_gamepads: ^0.1.0 From 71468a73bdf903f042874807e7a6f54ea37c4283 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 23:21:38 +0200 Subject: [PATCH 31/65] fix: make power up in example game look more like a lightning and less like a leg --- .../example/assets/images/power_up.png | Bin 900 -> 863 bytes .../example/assets/images/power_up.svg | 15 ++++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/flutter_gamepads/example/assets/images/power_up.png b/packages/flutter_gamepads/example/assets/images/power_up.png index a9f16c6c9c4749613ffedc4ee4b7ceef85e447f8..a5c0069a8b8d8b5b852d3b80ead2384d00de6378 100644 GIT binary patch delta 783 zcmV+q1MvKW2j2#eU4H`XNkltlbAOT08o4Kzof1%XQlRT< zR=P$@Q2^icoYc5w!KEu0u^w_78swWJ1|ni>l<8EjTtVm0cEpUPj$PX^76;j57q4&F zu7TSTIrZZwb%%};A1q$I7QYBv0|7POi2#m@Fxo+N-Ay_ocT;0ivZ161H8zXQPB0zG zB_ilLg`o9VYWvFSN5zW~qpv1}YR z1$u9dXX#=ZPaebkTZI=*NW~(e!5|sKqChWcD`v9l^jW%Jz8V;tIRorxUV+W9uax%yeB%@OcR=EPom3|L_4WUcyYo*=7gFwE~)A z^@0Eie@4Gg1ZryOw1geE8s+V<63EPCvYXdB4QEhsM}XGxG) z*XRD~|MUh~62#DT3Y=iMs{tQtf@O%;+wZD9fPrv05|PJFpj@TE?_)z%2!G%F-UE2l z%=e^Xr+-r{#{_g;%|?-(pZ9xi#GqzBMpvA5H*UzFAJu;)kfE`zDny#6cjuAg#Cwul zY-o^rr&!?P9Z2)Crlc4j!1Gq9t0!(I+2ZDKaG`)@apv3wx&WR$MHE~Jhb1~<1lqr_ zyZISk@7#rXb5WYcBDaUTBGA$zW~`I7`)VI?p5l_6Dc7*wb< zCc3!|LvGDt-KD%PDHLkV1m|$gzf+U delta 820 zcmV-41Izs1280KYU4H`+Nklv!50w(^}&h{KG+LY5u_HygjPy}P8}}dvlf&5hgNx6Uq)u(tk;6X;G73JmV{+@V^zT zz{!i(<-dvx=?+&u!ho5o&y*+xpb{ec`!9S&nZ*(yM5D`zK(O^~U za#AlIAlc`o<9|^vVl|Oo(!d;DJkPfv7MIg@n)8Q_;Jmpua|SG1iVD=SLl>{CYxpF| zmP?mO{&|s625Mi!6e+MRyg=2BnSew>Y}aAs{zGIQ_m{5Cs)KM7N=@Z!EikVh7&)va zzTMPLDw8br!u^{_I7BKKq_*N77#W2E$&?MbeCfQM27d*9zn}SIR)NNa)HjE)@Aj1L z{_ktZ(kS*4lQtck@=9+pJ!0{&^9;q2AIV3;JTgt@mb-K9BQQ;jh()IV{t)@m!(^it zeTKmY?@O+A1gR7~C|=&{9s2(DJh^n1ws>6rFMqr2BjTL`5(%-L3>)|EC+%dQp&n61 z>MKF6B>*5Em)~563qPH}{qz&6t7}+3t^xq^gKRz9fm>4z`5e}`3+U>Wr`asqzduf@ zs)~SVst?Ci06^?A$DZ_aEA24VCzYv!CjlZNj?Fp|3YGGK_n*>hawp(tpX<_C2?1K2 y(Jh + inkscape:current-layer="layer1" + showgrid="false" /> From e42133054c2d7350f9051b20b4e34ec37c493209 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 23:36:10 +0200 Subject: [PATCH 32/65] add: show icons in example selector --- packages/flutter_gamepads/example/lib/main.dart | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 7f1b06fb5..059c87ab4 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:flutter_gamepads_example/flame_example/main.dart'; @@ -8,11 +10,12 @@ void main() { } enum Example { - flutter('A pure Flutter example'), - flame('A Flame game example'); + flutter('Flutter Example', 'A pure Flutter example'), + flame('Flame Example', 'A Flame game with overlays'); - const Example(this.description); + const Example(this.label, this.description); + final String label; final String description; } @@ -59,8 +62,14 @@ class _ChooserAppState extends State { }, child: Column( children: [ + if (ex == Example.flame) + Image.asset('assets/images/spaceship.png'), + if (ex == Example.flutter) + const FlutterLogo( + size: 32, + ), Text( - ex.name[0].toUpperCase() + ex.name.substring(1), + ex.label, style: Theme.of(context).textTheme.titleMedium, ), Text(ex.description), From b35fc460911096d25b445a2352fdad1ef7291800 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Wed, 1 Apr 2026 23:42:02 +0200 Subject: [PATCH 33/65] chore: Fix dart anazyle --- packages/flutter_gamepads/example/lib/main.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index 059c87ab4..a39cdd292 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_gamepads/flutter_gamepads.dart'; import 'package:flutter_gamepads_example/flame_example/main.dart'; From f08ac2ca609abdd9e8dd886c44266c15089576c4 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 10:28:36 +0200 Subject: [PATCH 34/65] chore: Add emty line at end of README.md --- packages/flutter_gamepads/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 77dcaf27c..97eb3225a 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -174,4 +174,4 @@ Flame game that you want users to be able to navigate with their gamepad. `GamepadInterceptor` to do it locally if you need to guard closing the dialog by some condition. 4. If you need to disable `GamepadControl` while in-game you can do so by setting - `ignoreEvents = true` on it. \ No newline at end of file + `ignoreEvents = true` on it. From 73cf9d207ba04034ea4eb9114440e37b165d137a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 10:37:01 +0200 Subject: [PATCH 35/65] fix: left vs right in example --- .../example/lib/flutter_example/pages/game_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart index dbebe9cbe..dec2c8451 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart @@ -111,7 +111,7 @@ class _TickTackToeState extends State<_TickTackToe> { 'You can use the D-pad or right stick to move focus directionally up/down/left/right' ' which is supported via GamepadInterceptor.' '\n\n' - 'Left stick only works while focus is within the 3x3 grid.', + 'Right stick only works while focus is within the 3x3 grid.', style: TextStyle( fontStyle: FontStyle.italic, color: Colors.white70, From 52b84a0ab6d56a0c1bfb915ad952b5100669d17a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 18:24:59 +0200 Subject: [PATCH 36/65] refactor: tidy up the example switcher and document key parts of examples --- .../example/lib/flame_example/game.dart | 14 +++ .../example/lib/flame_example/main.dart | 39 ++------- .../example/lib/flame_example/theme.dart | 34 ++++++++ .../example/lib/flutter_example/main.dart | 8 ++ .../example/lib/flutter_example/theme.dart | 3 + .../flutter_gamepads/example/lib/main.dart | 85 ++++++++++++------- 6 files changed, 122 insertions(+), 61 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/flame_example/theme.dart diff --git a/packages/flutter_gamepads/example/lib/flame_example/game.dart b/packages/flutter_gamepads/example/lib/flame_example/game.dart index 0f11db9d1..52efaffc0 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/game.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/game.dart @@ -4,16 +4,20 @@ import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; import 'package:flutter_gamepads_example/flame_example/world.dart'; +/// This is our flame game. class MyGame extends FlameGame with HasKeyboardHandlerComponents { final GameState gameState = GameState(); final void Function()? exitApp; MyGame(this.exitApp) : super(world: MyWorld()); + /// Is any dialog style overlay active? bool get anyDialogOpen => overlays.activeOverlays.contains(MyOverlays.help.name) || overlays.activeOverlays.contains(MyOverlays.upgrade.name); + /// Update flame engine pause based on if the game should currently + /// be paused or not. void updateEnginePause() { final shouldBePaused = gameState.userPaused.value || anyDialogOpen; if (shouldBePaused != paused) { @@ -25,16 +29,26 @@ class MyGame extends FlameGame with HasKeyboardHandlerComponents { } } + /// Show an overlay + /// + /// Automatically pauses the game engine if the overlay is a dialog + /// according to [anyDialogOpen]. void showOverlay(MyOverlays overlay) { overlays.add(overlay.name); updateEnginePause(); } + /// Hide an overlay + /// + /// Automatically unpauses the game if there are no longer any open dialogs + /// according to [anyDialogOpen] and the user has not manually paused the + /// game. void hideOverlay(MyOverlays overlay) { overlays.remove(overlay.name); updateEnginePause(); } + /// Close all dialog style overlays void hideAllDialogs() { overlays.removeAll([ MyOverlays.help.name, diff --git a/packages/flutter_gamepads/example/lib/flame_example/main.dart b/packages/flutter_gamepads/example/lib/flame_example/main.dart index 5586b6892..b4260f55f 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/main.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/main.dart @@ -6,12 +6,15 @@ import 'package:flutter_gamepads_example/flame_example/overlays/help_overlay.dar import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; import 'package:flutter_gamepads_example/flame_example/overlays/statusbar.dart'; import 'package:flutter_gamepads_example/flame_example/overlays/upgrade_overlay.dart'; +import 'package:flutter_gamepads_example/flame_example/theme.dart'; void main() { runApp(const MyFlameApp()); } class MyFlameApp extends StatelessWidget { + /// Callback provided by the Example chooser that allow the + /// example to signal that user wants to exit the example. final void Function()? exitApp; const MyFlameApp({this.exitApp, super.key}); @@ -19,38 +22,12 @@ class MyFlameApp extends StatelessWidget { Widget build(BuildContext context) { final game = MyGame(exitApp); return MaterialApp( - theme: - ThemeData.from( - colorScheme: ColorScheme.dark( - primary: Colors.orange[700]!, - surface: Color.lerp(Colors.orange[900], Colors.grey[800], 0.7)!, - ), - ).copyWith( - dialogTheme: DialogThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusGeometry.circular(5), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - shape: WidgetStateProperty.resolveWith((state) { - return RoundedRectangleBorder( - side: BorderSide( - color: state.contains(WidgetState.focused) - ? Colors.lightGreenAccent - : Colors.transparent, - width: 4, - strokeAlign: -0.5, - ), - borderRadius: BorderRadiusGeometry.circular( - state.contains(WidgetState.focused) ? 2 : 5, - ), - ); - }), - ), - ), - ), + theme: buildTheme(), home: GamepadControl( + // An alternative to closing dialogs globally here is to wrap + // each dialog with a GamepadInterceptor to be able to locally + // catch the DismissIntent there. That option can be useful if + // you need to prevent closing in some situations. onBeforeIntent: (activator, intent) { if (intent is DismissIntent && game.anyDialogOpen) { game.hideAllDialogs(); diff --git a/packages/flutter_gamepads/example/lib/flame_example/theme.dart b/packages/flutter_gamepads/example/lib/flame_example/theme.dart new file mode 100644 index 000000000..ab4692f19 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/theme.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +ThemeData buildTheme() { + return ThemeData.from( + colorScheme: ColorScheme.dark( + primary: Colors.orange[700]!, + surface: Color.lerp(Colors.orange[900], Colors.grey[800], 0.7)!, + ), + ).copyWith( + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusGeometry.circular(5), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + /// Provide an expressive border color for focused buttons. + shape: WidgetStateProperty.resolveWith((state) { + return RoundedRectangleBorder( + side: BorderSide( + color: state.contains(WidgetState.focused) + ? Colors.lightGreenAccent + : Colors.transparent, + width: 4, + ), + borderRadius: BorderRadiusGeometry.circular( + state.contains(WidgetState.focused) ? 2 : 5, + ), + ); + }), + ), + ), + ); +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/main.dart b/packages/flutter_gamepads/example/lib/flutter_example/main.dart index 8b771e59a..607ee0195 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/main.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/main.dart @@ -10,11 +10,19 @@ void main() { } class MyFlutterApp extends StatelessWidget { + /// Callback provided by the Example chooser that allow the + /// example to signal that user wants to exit the example. final void Function()? exitApp; + const MyFlutterApp({this.exitApp, super.key}); @override Widget build(BuildContext context) { + // The GamepadControl widget here in the root provides user ability + // to control the app with their gamepad. At some places through + // out flutter_example, there is GamepadInterceptor widgets that + // intercepts Gamepad input before GamepadControl invokes the mapped + // intent for the Gamepad input. return GamepadControl( child: MaterialApp( theme: appTheme(), diff --git a/packages/flutter_gamepads/example/lib/flutter_example/theme.dart b/packages/flutter_gamepads/example/lib/flutter_example/theme.dart index bccdf81b5..8a98d3457 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/theme.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/theme.dart @@ -5,12 +5,15 @@ ThemeData appTheme() { colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), ); const focusColor = Colors.orange; + // A base border with highlight color for focused state. final focusBorder = WidgetStateBorderSide.resolveWith((state) { if (state.contains(WidgetState.focused)) { return const BorderSide(color: focusColor, width: 3); } return const BorderSide(color: Colors.transparent, width: 3); }); + // Apply the border style to different types of buttons used in the example + // app return theme.copyWith( focusColor: focusColor, elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart index a39cdd292..9fc01916f 100644 --- a/packages/flutter_gamepads/example/lib/main.dart +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -7,6 +7,7 @@ void main() { runApp(const ChooserApp()); } +/// Description of example apps enum Example { flutter('Flutter Example', 'A pure Flutter example'), flame('Flame Example', 'A Flame game with overlays'); @@ -15,8 +16,25 @@ enum Example { final String label; final String description; + + Widget imageBuilder(BuildContext context) { + return switch (this) { + Example.flame => Image.asset('assets/images/spaceship.png'), + Example.flutter => const FlutterLogo(size: 32), + }; + } + + Widget exampleAppBuilder(BuildContext context, void Function() exitApp) { + return switch (this) { + Example.flame => MyFlameApp(exitApp: exitApp), + Example.flutter => MyFlutterApp(exitApp: exitApp), + }; + } } +/// A chooser app that lets user select between two example apps: +/// * Flutter example +/// * Flame example class ChooserApp extends StatefulWidget { const ChooserApp({super.key}); @@ -25,18 +43,23 @@ class ChooserApp extends StatefulWidget { } class _ChooserAppState extends State { - Example? example; + Example? selectedExample; @override Widget build(BuildContext context) { - return switch (example) { - Example.flame => MyFlameApp(exitApp: exitApp), - Example.flutter => MyFlutterApp(exitApp: exitApp), - null => buildSelectionUi(context), + return switch (selectedExample) { + (final Example example) => example.exampleAppBuilder(context, exitApp), + null => buildExampleSelectionUi(context), }; } - Widget buildSelectionUi(BuildContext context) { + Widget buildExampleSelectionUi(BuildContext context) { + // The GamepadControl widget provides user ability to control the UI + // with their gamepad. + // + // It has to sit here in buildEXampleSelectionUI so that it doesn't + // build when one of the example apps builds as they provide their + // own setup with a GamepadControl. return GamepadControl( child: MaterialApp( theme: _theme, @@ -52,29 +75,7 @@ class _ChooserAppState extends State { ).textTheme.titleLarge!.copyWith(color: Colors.white), ), ...Example.values.map( - (ex) => Padding( - padding: const EdgeInsets.only(top: 20), - child: FilledButton( - onPressed: () { - setState(() => example = ex); - }, - child: Column( - children: [ - if (ex == Example.flame) - Image.asset('assets/images/spaceship.png'), - if (ex == Example.flutter) - const FlutterLogo( - size: 32, - ), - Text( - ex.label, - style: Theme.of(context).textTheme.titleMedium, - ), - Text(ex.description), - ], - ), - ), - ), + (example) => buildExampleButton(context, example), ), ], ), @@ -84,9 +85,31 @@ class _ChooserAppState extends State { ); } + Widget buildExampleButton(BuildContext context, Example example) { + return Padding( + padding: const EdgeInsets.only(top: 20), + child: FilledButton( + onPressed: () { + setState(() => selectedExample = example); + }, + child: Column( + children: [ + example.imageBuilder(context), + Text( + example.label, + style: Theme.of(context).textTheme.titleMedium, + ), + Text(example.description), + ], + ), + ), + ); + } + + /// Exit selected example app void exitApp() { setState(() { - example = null; + selectedExample = null; }); } } @@ -101,6 +124,8 @@ ThemeData get _theme => filledButtonTheme: FilledButtonThemeData( style: ButtonStyle( padding: const WidgetStatePropertyAll(EdgeInsets.all(30)), + // An expressive border for focused buttons makes it easier to + // use the chooser app with gamepad and keyboard input. side: WidgetStateProperty.resolveWith((state) { return state.contains(WidgetState.focused) ? BorderSide( From fec1925b1b73570a713f9e303ea93cdda130c0a4 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 18:25:55 +0200 Subject: [PATCH 37/65] change: remove autofocus from flutter example to make it look and feeel as usual for mouse users While it is an example for a Gamepad package, the idea is to provide access for gamepad users while still allowing the app to be multi-modal and support various types of input modes. --- .../example/lib/flutter_example/pages/home_page.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart index 8aa879715..e3abded0e 100644 --- a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart @@ -21,7 +21,6 @@ class HomePage extends StatelessWidget { Align( alignment: AlignmentGeometry.centerRight, child: FilledButton( - autofocus: exitApp == null, onPressed: () => Navigator.of(context).pop(), child: const Icon(Icons.chevron_left, semanticLabel: 'Close'), ), @@ -29,7 +28,6 @@ class HomePage extends StatelessWidget { if (exitApp != null) ...[ const SizedBox(height: 50), FilledButton( - autofocus: true, onPressed: exitApp, child: const Text('Exit Flutter Example'), ), @@ -166,7 +164,6 @@ class HomePage extends StatelessWidget { ), actions: [ FilledButton( - autofocus: true, onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), From f3150d960a929f1ddd123fd97b67594bbcc6aa44 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 18:43:02 +0200 Subject: [PATCH 38/65] docs: Update the intro of README --- packages/flutter_gamepads/README.md | 38 +++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 97eb3225a..c4e2a2ee8 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -1,31 +1,26 @@ # flutter_gamepads -A Flutter plugin to handle gamepad input across multiple platforms. +A Flutter package that maps gamepad input to UI interaction. It is based on the same +Flutter focus and intent systems that keyboard navigation in Flutter at its core is based on. + +This means that for a large part, the same effort you spend on supporting keyboard and +screen reader users also benefit gamepad users and vice versa. + +The philosophy is that you just add Gamepad support to your app to extend its multi-modality +of user input. ## Supported interaction -Using just `GamepadControl` out-of-the-box allow users to change focus around your app -similar to using the Tab key, but using the D-pad buttons on their gamepad. Several -'simple' widgets like buttons, DropdownMenu, Switch etc. just works. +Using just `GamepadControl` users of your app can move focus around your app similar to using +the Tab key, but using the D-pad buttons on their gamepad. Several 'simple' widgets like +buttons, DropdownMenu, Switch etc. just works. Some more complex interactive widgets like eg. the Slider widget needs some special attention to support. This package provides a `GamepadInterceptor` widget that you can use to handle those situations. -### Default bindings - -* Activate: A -* Dismiss: B -* Previous focus: D-pad up or D-pad left -* Next focus: D-pad down or D-pad right -* Scroll up: Right stick up -* Scroll down: Right stick down -* Scroll left: Right stick left -* Scroll right: Right stick right - - ## Usage @@ -40,6 +35,17 @@ GamepadControl( ) ``` +That will give you these default input bindings: + +* Activate: A +* Dismiss: B +* Previous focus: D-pad up or D-pad left +* Next focus: D-pad down or D-pad right +* Scroll up: Right stick up +* Scroll down: Right stick down +* Scroll left: Right stick left +* Scroll right: Right stick right + ### GamepadInterceptor From c1c218378d95b51032e3c91adbda80d082c3dffd Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 18:57:16 +0200 Subject: [PATCH 39/65] chore: change ul style to dash in README --- README.md | 4 ++-- packages/flutter_gamepads/README.md | 16 ++++++++-------- packages/flutter_gamepads/example/README.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7cdbe2f89..328c4fecf 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,8 @@ which is statically linked. ## Bridge packages -* [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - Provides a GamepadCallbacks component mixin for your Flame games -* [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users to navigating a Flutter widgets tree using a gamepad. +- [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - Provides a GamepadCallbacks component mixin for your Flame games +- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users to navigating a Flutter widgets tree using a gamepad. ## Support diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index c4e2a2ee8..ce71b63de 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -37,14 +37,14 @@ GamepadControl( That will give you these default input bindings: -* Activate: A -* Dismiss: B -* Previous focus: D-pad up or D-pad left -* Next focus: D-pad down or D-pad right -* Scroll up: Right stick up -* Scroll down: Right stick down -* Scroll left: Right stick left -* Scroll right: Right stick right +- Activate: A +- Dismiss: B +- Previous focus: D-pad up or D-pad left +- Next focus: D-pad down or D-pad right +- Scroll up: Right stick up +- Scroll down: Right stick down +- Scroll left: Right stick left +- Scroll right: Right stick right ### GamepadInterceptor diff --git a/packages/flutter_gamepads/example/README.md b/packages/flutter_gamepads/example/README.md index 657aec1e1..ad2d05afe 100644 --- a/packages/flutter_gamepads/example/README.md +++ b/packages/flutter_gamepads/example/README.md @@ -4,5 +4,5 @@ A simple example project showcasing the flutter_gamepads plugin. The example project consists of two distinct different apps: -* [A Flutter example](lib/flutter_example/) -* [A Flame game example](lib/flame_example/) +- [A Flutter example](lib/flutter_example/) +- [A Flame game example](lib/flame_example/) From da626cfe0162e948376d7fcf253fa114177cceff Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 19:00:01 +0200 Subject: [PATCH 40/65] chore: Fix line length in README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 328c4fecf..ca1650745 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,10 @@ which is statically linked. ## Bridge packages -- [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - Provides a GamepadCallbacks component mixin for your Flame games -- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users to navigating a Flutter widgets tree using a gamepad. +- [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - + Provides a GamepadCallbacks component mixin for your Flame games +- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users + to navigating a Flutter widgets tree using a gamepad. ## Support From 03dab9c6ffe6a05dc1270031f1355ed63a791067 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 19:15:19 +0200 Subject: [PATCH 41/65] docs: update descirption in main README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ca1650745..5b521f36a 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,9 @@ which is statically linked. - [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - Provides a GamepadCallbacks component mixin for your Flame games -- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that emit intents for users - to navigating a Flutter widgets tree using a gamepad. +- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that maps gamepad input + to UI interaction with your Flutter widgets. In regular Flutter apps as well as for Flame + overlays. ## Support From 540594623ea355a101df48e2b1f35b1a248b343e Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 19:35:18 +0200 Subject: [PATCH 42/65] refactor: extract component files in flame example and improve documentation of the example --- .../flame_example/components/power_up.dart | 13 ++ .../flame_example/components/spaceship.dart | 140 ++++++++++++++++++ .../example/lib/flame_example/main.dart | 5 + .../example/lib/flame_example/world.dart | 133 +---------------- 4 files changed, 160 insertions(+), 131 deletions(-) create mode 100644 packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart create mode 100644 packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart diff --git a/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart new file mode 100644 index 000000000..24d156fb3 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart @@ -0,0 +1,13 @@ + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +class PowerUp extends SpriteComponent { + @override + Future onLoad() async { + sprite = await Sprite.load('power_up.png'); + anchor = Anchor.center; + add(RectangleHitbox(collisionType: CollisionType.passive)); + return super.onLoad(); + } +} \ No newline at end of file diff --git a/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart b/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart new file mode 100644 index 000000000..17876890e --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gamepads_example/flame_example/components/power_up.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; +import 'package:gamepads/gamepads.dart'; + +class SpaceShip extends SpriteComponent + with KeyboardHandler, CollisionCallbacks, HasGameReference { + /// inputX refer to request to change ship rotation + double inputX = 0; + + /// inputY refer to request to accelerate/bake the ship + double inputY = 0; + + /// Velocity in world coordinates + Vector2 velocity = Vector2.zero(); + StreamSubscription? _subscription; + + static const rotationVelocity = 2.0; + static const accel = 50.0; + static const decel = 100.0; + static const autoBrakeDecel = 25.0; + static const gamepadDeadZone = 0.15; + + @override + Future onLoad() async { + sprite = await Sprite.load('spaceship.png'); + anchor = Anchor.center; + add(RectangleHitbox()); + _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); + return super.onLoad(); + } + + @override + void onRemove() { + _subscription?.cancel(); + super.onRemove(); + } + + @override + void update(double dt) { + super.update(dt); + + // Get installed upgrades + final hasTurnRail = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.turnRail, + ); + final hasAutoBrake = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.autoBrake, + ); + + // Update angle based on user input (inputX) + angle += inputX * dt * rotationVelocity; + + // Get fraction of travel in angle direction in x and y world coordinates. + // (velocity independent base-fraction) + final angleX = cos(angle - pi / 2); + final angleY = sin(angle - pi / 2); + + // Compute change in velocity based on user input (inputY) + final ddy = inputY > 0 ? accel : decel; + if (hasTurnRail) { + var dy = velocity.length; + dy = max(0, dy + inputY * dt * ddy); + velocity.x = angleX * dy; + velocity.y = angleY * dy; + } else { + velocity.x += angleX * dt * max(inputY, 0) * ddy; + velocity.y += angleY * dt * max(inputY, 0) * ddy; + } + + // Auto brake? + if (hasAutoBrake && inputY < 0.01 && velocity.length >= 0.001) { + velocity.clampLength(0, max(0, velocity.length - dt * autoBrakeDecel)); + } + + // Update spaceship position + if (velocity.length > 0.001) { + position.x += velocity.x * dt; + position.y += velocity.y * dt; + } + } + + // Keyboard input support + @override + bool onKeyEvent(KeyEvent event, Set keysPressed) { + if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA)) { + inputX = -1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD)) { + inputX = 1; + } else { + inputX = 0; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || + keysPressed.contains(LogicalKeyboardKey.keyW)) { + inputY = 1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || + keysPressed.contains(LogicalKeyboardKey.keyS)) { + inputY = -1; + } else { + inputY = 0; + } + + return super.onKeyEvent(event, keysPressed); + } + + /// Listener for Gamepad events. This process events directly from + /// underlying gamepads plugin, and not via GamepadControl. + /// + /// Whenever a dialog is opened, the flame game engine is paused causing + /// no update() to occur for the spaceship. + void onGamepadEvent(NormalizedGamepadEvent event) { + if (event.axis == GamepadAxis.leftStickX) { + inputX = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.rightTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.leftTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? -event.value : 0; + } + } + + @override + void onCollision(Set intersectionPoints, PositionComponent other) { + if (other is PowerUp) { + game.world.remove(other); + game.gameState.powerUps.value += 1; + } + + super.onCollision(intersectionPoints, other); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/main.dart b/packages/flutter_gamepads/example/lib/flame_example/main.dart index b4260f55f..c5f5092ac 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/main.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/main.dart @@ -23,6 +23,11 @@ class MyFlameApp extends StatelessWidget { final game = MyGame(exitApp); return MaterialApp( theme: buildTheme(), + // The role here of GamepadControl is to allow users to interact + // with the Flutter widgets in overlays of the Flame game. + // + // For Spaceship control, the game uses direct gamepads event + // processing. See components/spaceship.dart. home: GamepadControl( // An alternative to closing dialogs globally here is to wrap // each dialog with a GamepadInterceptor to be able to locally diff --git a/packages/flutter_gamepads/example/lib/flame_example/world.dart b/packages/flutter_gamepads/example/lib/flame_example/world.dart index 778ad5977..cc6cb4881 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/world.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/world.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_gamepads_example/flame_example/components/power_up.dart'; +import 'package:flutter_gamepads_example/flame_example/components/spaceship.dart'; import 'package:flutter_gamepads_example/flame_example/game.dart'; -import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; -import 'package:gamepads/gamepads.dart'; class MyWorld extends World with HasCollisionDetection, HasGameReference { @@ -34,130 +32,3 @@ class MyWorld extends World return super.onLoad(); } } - -class SpaceShip extends SpriteComponent - with KeyboardHandler, CollisionCallbacks, HasGameReference { - double inputX = 0; - double inputY = 0; - Vector2 velocity = Vector2.zero(); - double dy = 0; - StreamSubscription? _subscription; - - static const rotationVelocity = 2.0; - static const accel = 50.0; - static const decel = 100.0; - static const autoBrakeDecel = 25.0; - static const gamepadDeadZone = 0.15; - - @override - Future onLoad() async { - sprite = await Sprite.load('spaceship.png'); - anchor = Anchor.center; - add(RectangleHitbox()); - _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); - return super.onLoad(); - } - - @override - void onRemove() { - _subscription?.cancel(); - super.onRemove(); - } - - @override - void update(double dt) { - _basicUpdate(dt); - super.update(dt); - } - - void _basicUpdate(double dt) { - final hasTurnRail = game.gameState.installedUpgrades.value.contains( - SpaceshipUpgrades.turnRail, - ); - final hasAutoBrake = game.gameState.installedUpgrades.value.contains( - SpaceshipUpgrades.autoBrake, - ); - - angle += inputX * dt * rotationVelocity; - - final angleX = cos(angle - pi / 2); - final angleY = sin(angle - pi / 2); - - final ddy = inputY > 0 ? accel : decel; - - if (hasTurnRail) { - var dy = velocity.length; - dy = max(0, dy + inputY * dt * (inputY > 0 ? accel : decel)); - velocity.x = angleX * dy; - velocity.y = angleY * dy; - } else { - velocity.x += angleX * dt * max(inputY, 0) * ddy; - velocity.y += angleY * dt * max(inputY, 0) * ddy; - } - - if (hasAutoBrake && inputY < 0.01 && velocity.length >= 0.001) { - velocity.clampLength(0, max(0, velocity.length - dt * autoBrakeDecel)); - } - - if (velocity.length > 0.001) { - position.x += velocity.x * dt; - position.y += velocity.y * dt; - } - } - - @override - bool onKeyEvent(KeyEvent event, Set keysPressed) { - if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || - keysPressed.contains(LogicalKeyboardKey.keyA)) { - inputX = -1; - } else if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || - keysPressed.contains(LogicalKeyboardKey.keyD)) { - inputX = 1; - } else { - inputX = 0; - } - if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || - keysPressed.contains(LogicalKeyboardKey.keyW)) { - inputY = 1; - } else if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || - keysPressed.contains(LogicalKeyboardKey.keyS)) { - inputY = -1; - } else { - inputY = 0; - } - - return super.onKeyEvent(event, keysPressed); - } - - @override - void onCollision(Set intersectionPoints, PositionComponent other) { - if (other is PowerUp) { - game.world.remove(other); - game.gameState.powerUps.value += 1; - } - - super.onCollision(intersectionPoints, other); - } - - void onGamepadEvent(NormalizedGamepadEvent event) { - if (event.axis == GamepadAxis.leftStickX) { - inputX = event.value.abs() > gamepadDeadZone ? event.value : 0; - } - if (event.axis == GamepadAxis.rightTrigger) { - inputY = event.value.abs() > gamepadDeadZone ? event.value : 0; - } - if (event.axis == GamepadAxis.leftTrigger) { - inputY = event.value.abs() > gamepadDeadZone ? -event.value : 0; - } - } -} - -class PowerUp extends SpriteComponent { - @override - Future onLoad() async { - sprite = await Sprite.load('power_up.png'); - anchor = Anchor.center; - add(RectangleHitbox(collisionType: CollisionType.passive)); - return super.onLoad(); - } -} From 43cbaacea866d2ba4013c4d6cb1c8adbd5281b8a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Thu, 2 Apr 2026 19:37:13 +0200 Subject: [PATCH 43/65] chore: format code --- .../example/lib/flame_example/components/power_up.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart index 24d156fb3..f46bde8fc 100644 --- a/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart +++ b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart @@ -1,4 +1,3 @@ - import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; @@ -10,4 +9,4 @@ class PowerUp extends SpriteComponent { add(RectangleHitbox(collisionType: CollisionType.passive)); return super.onLoad(); } -} \ No newline at end of file +} From 3e515e03af39416f466cf74a0c8ced780be8178d Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 01:54:37 +0200 Subject: [PATCH 44/65] docs: Update flutter_gamepads README (major update) --- packages/flutter_gamepads/README.md | 278 +++++++++++------- .../flutter_gamepads/docs/input_diagram.mmd | 29 ++ .../flutter_gamepads/docs/input_diagram.svg | 1 + 3 files changed, 198 insertions(+), 110 deletions(-) create mode 100644 packages/flutter_gamepads/docs/input_diagram.mmd create mode 100644 packages/flutter_gamepads/docs/input_diagram.svg diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index ce71b63de..24b5a651d 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -1,7 +1,7 @@ # flutter_gamepads -A Flutter package that maps gamepad input to UI interaction. It is based on the same -Flutter focus and intent systems that keyboard navigation in Flutter at its core is based on. +A Flutter package that maps gamepad input to UI interaction. It is built on Flutter’s focus +and intent system which powers keyboard navigation in Flutter. This means that for a large part, the same effort you spend on supporting keyboard and screen reader users also benefit gamepad users and vice versa. @@ -9,161 +9,160 @@ screen reader users also benefit gamepad users and vice versa. The philosophy is that you just add Gamepad support to your app to extend its multi-modality of user input. +## Features -## Supported interaction +**Input support** +* Gamepad buttons and axes can be used as input +* Input repetition (on long press/activation) +* Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms + support library -Using just `GamepadControl` users of your app can move focus around your app similar to using -the Tab key, but using the D-pad buttons on their gamepad. Several 'simple' widgets like -buttons, DropdownMenu, Switch etc. just works. +**Output support** +- Move focus +- Activate focused button +- Dismiss +- Scroll +- (actually anything that you can do with Intents in Flutter, plus more with callbacks) -Some more complex interactive widgets like eg. the Slider widget needs some special attention -to support. This package provides a `GamepadInterceptor` widget that you can use to handle -those situations. +**API and docs** +- A GamepadControl widget to wrap your app which in some cases is all you need. +- Callbacks that allow intercepting an Intent before it actually is emitted. +- Extensive example project showing how the package can be used in pure Flutter apps + as well as for Flame game overlays. +Can be used in both pure Flutter apps and in Flame games for overlays and menus. For Flame +usage, see the Flame-specific guidance later in the README. -## Usage +## Not included -### GamepadControl +This package does not magically "just work" in all cases. Your app has to work reasonably well +with the Flutter focus system and there can be some widgets that need some extra work to get +working. -Wrap your widgets with `GamepadControl` to allow users to navigate it using their gamepad. It is -recommended to at any given time only have one `GamepadControl` in your widget tree. +Text input is currently not supported via Gamepad input. -```dart -GamepadControl( - child: MaterialApp(), -) -``` +If you just want to add gamepad support as game input without any UI navigation, you are probably +better off using the [gamepads](https://pub.dev/packages/gamepads) package directly. -That will give you these default input bindings: -- Activate: A -- Dismiss: B -- Previous focus: D-pad up or D-pad left -- Next focus: D-pad down or D-pad right -- Scroll up: Right stick up -- Scroll down: Right stick down -- Scroll left: Right stick left -- Scroll right: Right stick right +## Quick-start -### GamepadInterceptor +### Preparation -If you want to intercept a Gamepad intent locally next to a Widget you can do so with -`GamepadInterceptor`. Its `onBeforeIntent` is only called if a descendant widget has focus. +Use only TAB key and Space/Enter to navigate your app. If this works, it will likely work well +with gamepad input. -```dart -GamepadInterceptor( - onBeforeIntent: (activator, intent) { - if (intent is ScrollIntent) { - if (intent.direction = AxisDirection.right) { - setState(() _value = min(100, _value + 10)); - } else if (intent.direction = AxisDirection.left) { - setState(() _value = max(0, _value - 10)); - } - // Block actual emit of ScrollIntent - return false; - } - // Allow other intents such as focus change to occur - return true; - } - child: Slider( - value: _value, - max: 100, - // This setState never occur by Gamepad input, but is good to allow keyboard/mouse - // input as well. - onChange: (value) => setState(() => _value = value), - ) -) -``` +If there are widgets you cannot reach, fix that first. If there are widgets you cannot interact +with while they are focused, that can be handled specifically for gamepads. -Note that `GamepadInterceptor` must be placed below the `GamepadControl` widget in the -widget tree. +If you cannot clearly see which widget has focus, update the theme of your app and make for +example the border of focused widgets stand out in a different color. -An example of how to package an Gamepad-extended widget can be found in -[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). +### Gamepad support -### Changing the Gamepad bindings +1. Start by wrapping your MaterialApp with `GamepadControl`, which in some cases is all you need. +2. Test your app +3. If you find out that some widgets can't be controlled with a gamepad, add an `onBeforeIntent` + callback to `GamepadControl` or wrap each widget with a `GamepadInterceptor`. See below for + detailed explanation of callbacks. -You can customize the default gamepad bindings by providing a map between `GamepadActivator` -and any `Intent`. Flutter comes with a set of generally supported intents, but you can also -pass in your custom Intents as long as you provide Actions for them. -```dart -GamepadControl( - shortcuts: { - GamepadActivatorButton.a(): ActivateIntent(), - GamepadActivatorButton.b(): DismissIntent(), - // In addition to the .a, .b, .x, .. constructors you can pass in a GamepadButton - GamepadActivatorButton(GamepadButton.x): DismissIntent(), - GamepadActivatorButton.bumperLeft(): PreviousFocusIntent(), - GamepadActivatorButton.bumperRight(): NextFocusIntent(), - GamepadActivatorAxis.rightStickUp(): ScrollIntent( - direction: AxisDirection.up, - ), - // You can configure an axis with its threshold if you want. - GamepadActivatorAxis(GamepadAxis.rightStickY, -0.2): ScrollIntent( - direction: AxisDirection.down, - ), - }, - child: child, -) -``` +## How it works + + +### GamepadControl +This widget will listen to `gamepads` normalized input events and emit Intents originating +from the primary focused widget. -### Temporary disabling Gamepad input +By default GamepadControl comes with these bindings: -From `onBeforeIntent` in a `GamepadInterceptor` or the `GamepadControl` widget you can return -false to block an intent from being emitted. +- D-pad up: Previous focus +- D-pad left: Previous focus +- D-pad right: Next focus +- D-pad down: Next focus +- A: Activate +- B: Dismiss +- Right stick up: Scroll up +- Right stick left: Scroll left +- Right stick right: Scroll right +- Right stick down: Scroll down -On `GamepadControl` you may also set `ignoreEvents = true` to an an earlier level temporarily -block all Gamepad input processing. Setting `ignoreEvents` to true does clear currently activated -axes and resets repeats, while `onBeforeIntent` just block each intent from being emitted. +But they can be customized via the `shortcuts` parameter. It is not limited to the intents +above. Any class that inherits from the `Intent` base class can be used as the emitted intent +for a gamepad activator (button or axis). -## Implementation strategy recommendation +#### Multiple GamepadControl widgets +Note that if you have multiple `GamepadControl` widgets concurrently in your widget tree, they +will all emit intents on the `primaryFocus` focus node. Except if you set `ignoreEvents` or +use `onBeforeIntent` to block intents from all but one of the `GamepadControl` widgets. -### Step 1 - Clear focus indicators +The `GamepadControl` widget does not check if primaryFocus is a descendant of itself. -Use the TAB key on your keyboard and step through your app and verify that your widgets -clearly show if they are focused or not. -You may have to update your Theme and add an expressive border of the focused buttons for -example. +### Callbacks and the emit chain -If you notice that some widgets never receives focus you have to resolve this, by making -them focusable and verify with the TAB key this works. +The chain from received `NormalizedGamepadEvent` from `gamepads` package via callbacks to +emitting an intent is described by the diagram below. +![Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) -### Step 2 - Add default GamepadControl +If no GamepadInterceptor is found, or if GamepadControl.onBeforeIntent is not set, execution +continues as if true was returned. -Start with wrapping your MaterialApp or similar with the `GamepadControl` and then -try out your app with a gamepad. Take notice of which widgets in your app that doesn't -work out-of-the box. + +### GamepadInterceptor + +If you want to intercept a Gamepad intent locally next to a Widget you can do so with +`GamepadInterceptor`. Its `onBeforeIntent` is only called if a descendant widget has focus. ```dart -GamepadControl( - child: MaterialApp(), +GamepadInterceptor( + onBeforeIntent: (activator, intent) { + if (intent is ScrollIntent) { + // Handle scroll intent + print('Gamepad scroll ${intent.direction}'); + + // Block actual emit of ScrollIntent + return false; + } + // Allow other intents such as focus change to occur + return true; + } + child: YourWidget(), ) ``` +An example of how to build a gamepad extended widget can be found in +[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). + -### Step 3 - Add interceptors where needed +### Blocking Gamepad input -Then, wrap those problematic widgets with `GamepadInterceptor` and ensure that the widget -itself can receive focus. +There are four ways to block gamepad input from invoking intents: -Use `onBeforeIntent` to catch eg. the `ScrollIntent` and use that to implement interaction -with your widget. +1. Omitting the `GamepadControl` widget from your widget tree + - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. +2. `GamepadControl.ignoreEvents == true` + - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are reset. +3. `GamepadInterceptor.onBeforeIntent() => false` + - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() +4. `GamepadControl.onBeforeIntent() => false` + - Blocks each intent before it is passed on to Flutter. -Then test and repeat. +Method 1 and 2 are good for when you fully want to block gamepad control of Flutter UI. +Method 3 or 4 is good if you want to block specific intents. -### Flame specific guidance -`flutter_gamepad` can be helpful in scenarios when you have overlays in your +## Flame specific guidance + +`flutter_gamepads` can be helpful in scenarios when you have overlays in your Flame game that you want users to be able to navigate with their gamepad. 1. Wrap your `GameWidget` with a `GamepadControl` widget @@ -181,3 +180,62 @@ Flame game that you want users to be able to navigate with their gamepad. by some condition. 4. If you need to disable `GamepadControl` while in-game you can do so by setting `ignoreEvents = true` on it. + + +## Code example + +In the example folder there is both a full Flutter app and a full Flame game example showing +how the package can be used in those two scenarios. + +Here follows a brief code example: + +```dart +GamepadControl( + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + SwitchListTile( + title: const Text('Works with gamepads'), + value: switchValue, + onChanged: (value) => setState(() => switchValue = value), + ), + ElevatedButton(onPressed: () {}, Text('Can be clicked with gamepad')), + // This can be focused, but gamepad users cannot change the value + // The solution is given below. + Slider( + value: sliderValue, + label: 'Does not work with gamepads', + onChange: (value) => setState(() => sliderValue = value), + ), + // This slider can be operated with Gamepad due to the compatibility + // layer provided via GamepadInterceptor. + GamepadInterceptor( + onBeforeIntent: (activator, intent) { + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.right) { + setState(() _value = min(1.0, _value + 0.1)); + } else if (intent.direction == AxisDirection.left) { + setState(() _value = max(0.0, _value - 0.1)); + } + // Block actual emit of ScrollIntent + return false; + } + // Allow other intents such as focus change to occur + return true; + }, + child: Slider( + value: _value, + label: 'Works with gamepads', + max: 1.0, + // This setState never occur by Gamepad input, but is good to allow keyboard/mouse + // input as well. + onChange: (value) => setState(() => _value = value), + ), + ), + ], + ), + ), + ), +) +``` diff --git a/packages/flutter_gamepads/docs/input_diagram.mmd b/packages/flutter_gamepads/docs/input_diagram.mmd new file mode 100644 index 000000000..b7c6e18e0 --- /dev/null +++ b/packages/flutter_gamepads/docs/input_diagram.mmd @@ -0,0 +1,29 @@ +flowchart TD + input["Normalized Gamepad Input (gamepads package)"] + --> ignoreCheck{"ignoreEvents"} + + ignoreCheck -->|true| stopIgnore[STOP] + ignoreCheck -->|false| findActivator[Find matching GamepadActivator] + + findActivator --> keyEventType{"Activator event"} + + keyEventType -->|Activated| startRepeat[Start repeat timer for intent] + keyEventType -->|Canceled| stopRepeat[Stop repeat timer of intent] + stopRepeat --> stopAfterStopRepeat[STOP] + keyEventType -->|Neither| stopInvalid[STOP] + + timerEvent[Repeat timer event] + startRepeat --> findInterceptor + timerEvent --> findInterceptor + + findInterceptor[Find nearest GamepadInterceptor ancestor from primaryFocus] + + findInterceptor --> interceptorDecision{"GamepadInterceptor
.onBeforeIntent()?"} + + interceptorDecision -->|Return false| stopInterceptor[STOP] + interceptorDecision -->|Return true| controlDecision{"GamepadControl
.onBeforeIntent()?"} + + controlDecision -->|Return false| stopControl[STOP] + controlDecision -->|Return true| emitIntent[Emit Intent to primaryFocus] + + emitIntent --> actionsSystem[Flutter Actions system handles it] \ No newline at end of file diff --git a/packages/flutter_gamepads/docs/input_diagram.svg b/packages/flutter_gamepads/docs/input_diagram.svg new file mode 100644 index 000000000..0c92c99c8 --- /dev/null +++ b/packages/flutter_gamepads/docs/input_diagram.svg @@ -0,0 +1 @@ +

true

false

Activated

Canceled

Neither

Return false

Return true

Return false

Return true

Normalized Gamepad Input (gamepads package)

ignoreEvents

STOP

Find matching GamepadActivator

Activator event

Start repeat timer for intent

Stop repeat timer of intent

STOP

STOP

Repeat timer event

Find nearest GamepadInterceptor ancestor from primaryFocus

GamepadInterceptor
.onBeforeIntent()?

STOP

GamepadControl
.onBeforeIntent()?

STOP

Emit Intent to primaryFocus

Flutter Actions system handles it

\ No newline at end of file From 2bf905b4fe92cd8baaeeedac410c25cf0a8007a8 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:00:36 +0200 Subject: [PATCH 45/65] chore: markdown format --- packages/flutter_gamepads/README.md | 33 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 24b5a651d..5bd485843 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -9,22 +9,29 @@ screen reader users also benefit gamepad users and vice versa. The philosophy is that you just add Gamepad support to your app to extend its multi-modality of user input. + ## Features -**Input support** -* Gamepad buttons and axes can be used as input -* Input repetition (on long press/activation) -* Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms + +### Input support + +- Gamepad buttons and axes can be used as input +- Input repetition (on long press/activation) +- Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms support library -**Output support** + +### Output support + - Move focus - Activate focused button - Dismiss - Scroll - (actually anything that you can do with Intents in Flutter, plus more with callbacks) -**API and docs** + +### API and docs + - A GamepadControl widget to wrap your app which in some cases is all you need. - Callbacks that allow intercepting an Intent before it actually is emitted. - Extensive example project showing how the package can be used in pure Flutter apps @@ -147,12 +154,20 @@ An example of how to build a gamepad extended widget can be found in There are four ways to block gamepad input from invoking intents: 1. Omitting the `GamepadControl` widget from your widget tree + - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. + 2. `GamepadControl.ignoreEvents == true` - - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are reset. + + - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are + reset. + 3. `GamepadInterceptor.onBeforeIntent() => false` + - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() + 4. `GamepadControl.onBeforeIntent() => false` + - Blocks each intent before it is passed on to Flutter. Method 1 and 2 are good for when you fully want to block gamepad control of Flutter UI. @@ -228,8 +243,8 @@ GamepadControl( value: _value, label: 'Works with gamepads', max: 1.0, - // This setState never occur by Gamepad input, but is good to allow keyboard/mouse - // input as well. + // This setState never occur by Gamepad input, but is good to allow + // keyboard/mouse input as well. onChange: (value) => setState(() => _value = value), ), ), From add5783c2ba2ab62895202b3f8cd2fe8741d8e1e Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:05:26 +0200 Subject: [PATCH 46/65] chore: markdown format --- packages/flutter_gamepads/README.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 5bd485843..7dfdc7e34 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -154,21 +154,14 @@ An example of how to build a gamepad extended widget can be found in There are four ways to block gamepad input from invoking intents: 1. Omitting the `GamepadControl` widget from your widget tree - - - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. - + Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. 2. `GamepadControl.ignoreEvents == true` - - - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are - reset. - + Early check on each `gamepads` event, axis activation memory is reset and repeat timers are + reset. 3. `GamepadInterceptor.onBeforeIntent() => false` - - - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() - + Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() 4. `GamepadControl.onBeforeIntent() => false` - - - Blocks each intent before it is passed on to Flutter. + Blocks each intent before it is passed on to Flutter. Method 1 and 2 are good for when you fully want to block gamepad control of Flutter UI. From d76a27d0960ed90141294a54d912382fa49df3ec Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:06:26 +0200 Subject: [PATCH 47/65] chore: markdown format --- packages/flutter_gamepads/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 7dfdc7e34..872f07536 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -154,14 +154,14 @@ An example of how to build a gamepad extended widget can be found in There are four ways to block gamepad input from invoking intents: 1. Omitting the `GamepadControl` widget from your widget tree - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. + - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. 2. `GamepadControl.ignoreEvents == true` - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are - reset. + - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are + reset. 3. `GamepadInterceptor.onBeforeIntent() => false` - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() + - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() 4. `GamepadControl.onBeforeIntent() => false` - Blocks each intent before it is passed on to Flutter. + - Blocks each intent before it is passed on to Flutter. Method 1 and 2 are good for when you fully want to block gamepad control of Flutter UI. From 98d93f40d2b41b9c59c70c2151b3c3fb7a7f22aa Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:13:56 +0200 Subject: [PATCH 48/65] chore: line length --- packages/flutter_gamepads/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 872f07536..ff204842a 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -216,14 +216,15 @@ GamepadControl( label: 'Does not work with gamepads', onChange: (value) => setState(() => sliderValue = value), ), - // This slider can be operated with Gamepad due to the compatibility - // layer provided via GamepadInterceptor. + // This slider can be operated with Gamepad due to the + // compatibility layer provided via GamepadInterceptor. GamepadInterceptor( onBeforeIntent: (activator, intent) { if (intent is ScrollIntent) { if (intent.direction == AxisDirection.right) { setState(() _value = min(1.0, _value + 0.1)); - } else if (intent.direction == AxisDirection.left) { + } else if (intent.direction == + AxisDirection.left) { setState(() _value = max(0.0, _value - 0.1)); } // Block actual emit of ScrollIntent @@ -236,8 +237,8 @@ GamepadControl( value: _value, label: 'Works with gamepads', max: 1.0, - // This setState never occur by Gamepad input, but is good to allow - // keyboard/mouse input as well. + // This setState never occur by Gamepad input, but is + // good to allow keyboard/mouse input as well. onChange: (value) => setState(() => _value = value), ), ), From 8873ff64f71a5ff3b8c12b8008e6c6e733c7dd8b Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:16:57 +0200 Subject: [PATCH 49/65] docs: slim features section --- packages/flutter_gamepads/README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index ff204842a..db3c7c172 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -12,16 +12,14 @@ of user input. ## Features - -### Input support +Input: - Gamepad buttons and axes can be used as input - Input repetition (on long press/activation) - Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms support library - -### Output support +Output: - Move focus - Activate focused button @@ -29,8 +27,7 @@ of user input. - Scroll - (actually anything that you can do with Intents in Flutter, plus more with callbacks) - -### API and docs +API and docs: - A GamepadControl widget to wrap your app which in some cases is all you need. - Callbacks that allow intercepting an Intent before it actually is emitted. From de18cdd99701ac8e5d46178a8bc5583e2811299c Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:20:37 +0200 Subject: [PATCH 50/65] docs: slim 'Not included' --- packages/flutter_gamepads/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index db3c7c172..4e0827abe 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -46,9 +46,6 @@ working. Text input is currently not supported via Gamepad input. -If you just want to add gamepad support as game input without any UI navigation, you are probably -better off using the [gamepads](https://pub.dev/packages/gamepads) package directly. - ## Quick-start From 87bc23384e59972471051d6f013f563b6ce75886 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:26:13 +0200 Subject: [PATCH 51/65] docs: tweak intro + slim features --- packages/flutter_gamepads/README.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 4e0827abe..5d438517f 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -1,9 +1,9 @@ # flutter_gamepads -A Flutter package that maps gamepad input to UI interaction. It is built on Flutter’s focus -and intent system which powers keyboard navigation in Flutter. +A Flutter package that maps gamepad input to UI interaction. -This means that for a large part, the same effort you spend on supporting keyboard and +This package is built on Flutter’s focus and intent system which powers keyboard navigation in +Flutter. This means that for a large part, the same effort you spend on supporting keyboard and screen reader users also benefit gamepad users and vice versa. The philosophy is that you just add Gamepad support to your app to extend its multi-modality @@ -12,23 +12,15 @@ of user input. ## Features -Input: - -- Gamepad buttons and axes can be used as input -- Input repetition (on long press/activation) -- Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms - support library - -Output: - - Move focus - Activate focused button - Dismiss - Scroll - (actually anything that you can do with Intents in Flutter, plus more with callbacks) - -API and docs: - +- Gamepad buttons and axes can be used as input +- Input repetition (on long press/activation) +- Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms + support library - A GamepadControl widget to wrap your app which in some cases is all you need. - Callbacks that allow intercepting an Intent before it actually is emitted. - Extensive example project showing how the package can be used in pure Flutter apps From 00e37c7a6b656b5a2ed598eb050e5a0f215c6b04 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:27:07 +0200 Subject: [PATCH 52/65] docs: tweak --- packages/flutter_gamepads/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 5d438517f..5c818897e 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -25,9 +25,8 @@ of user input. - Callbacks that allow intercepting an Intent before it actually is emitted. - Extensive example project showing how the package can be used in pure Flutter apps as well as for Flame game overlays. - -Can be used in both pure Flutter apps and in Flame games for overlays and menus. For Flame -usage, see the Flame-specific guidance later in the README. +- Can be used in both pure Flutter apps and in Flame games for overlays and menus. For Flame + usage, see the Flame-specific guidance later in the README. ## Not included From ff4c1f25e45c525eac633e526af8de51c33d1766 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:27:58 +0200 Subject: [PATCH 53/65] docs: tweak --- packages/flutter_gamepads/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 5c818897e..e82618875 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -29,7 +29,7 @@ of user input. usage, see the Flame-specific guidance later in the README. -## Not included +### Not included This package does not magically "just work" in all cases. Your app has to work reasonably well with the Flutter focus system and there can be some widgets that need some extra work to get From 2855752e592e6baf2e3fe74ee950f6320e70058f Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:30:03 +0200 Subject: [PATCH 54/65] docs: move diagram down --- packages/flutter_gamepads/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index e82618875..df1f11f72 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -97,17 +97,6 @@ use `onBeforeIntent` to block intents from all but one of the `GamepadControl` w The `GamepadControl` widget does not check if primaryFocus is a descendant of itself. -### Callbacks and the emit chain - -The chain from received `NormalizedGamepadEvent` from `gamepads` package via callbacks to -emitting an intent is described by the diagram below. - -![Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) - -If no GamepadInterceptor is found, or if GamepadControl.onBeforeIntent is not set, execution -continues as if true was returned. - - ### GamepadInterceptor If you want to intercept a Gamepad intent locally next to a Widget you can do so with @@ -134,6 +123,17 @@ An example of how to build a gamepad extended widget can be found in [SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). +### Callbacks and the emit chain + +The chain from received `NormalizedGamepadEvent` from `gamepads` package via callbacks to +emitting an intent is described by the diagram below. + +![Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) + +If no GamepadInterceptor is found, or if GamepadControl.onBeforeIntent is not set, execution +continues as if true was returned. + + ### Blocking Gamepad input There are four ways to block gamepad input from invoking intents: From 937e3da5881a953fe401b75cb41d0c32510d024e Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 02:31:38 +0200 Subject: [PATCH 55/65] docs: add back GamepadControl code snippet for visual balance --- packages/flutter_gamepads/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index df1f11f72..b2665e851 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -70,6 +70,12 @@ example the border of focused widgets stand out in a different color. This widget will listen to `gamepads` normalized input events and emit Intents originating from the primary focused widget. +```dart +GamepadControl( + child: MaterialApp(), +) +``` + By default GamepadControl comes with these bindings: - D-pad up: Previous focus From 9832e3a87869db840b305ed632157d1655eda390 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:19:50 +0200 Subject: [PATCH 56/65] docs: focus more on usage and less on how it works in README.md --- packages/flutter_gamepads/README.md | 130 +++++++++++++++------------- 1 file changed, 70 insertions(+), 60 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index b2665e851..4d2a1b9fb 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -29,7 +29,7 @@ of user input. usage, see the Flame-specific guidance later in the README. -### Not included +### Limitations This package does not magically "just work" in all cases. Your app has to work reasonably well with the Flutter focus system and there can be some widgets that need some extra work to get @@ -62,82 +62,43 @@ example the border of focused widgets stand out in a different color. detailed explanation of callbacks. -## How it works +## API Usage +`GamepadControl` is the main widget of this package. You usually have exactly one of this widget +that wraps your MaterialApp or similar. -### GamepadControl -This widget will listen to `gamepads` normalized input events and emit Intents originating -from the primary focused widget. - -```dart -GamepadControl( - child: MaterialApp(), -) -``` - -By default GamepadControl comes with these bindings: - -- D-pad up: Previous focus -- D-pad left: Previous focus -- D-pad right: Next focus -- D-pad down: Next focus -- A: Activate -- B: Dismiss -- Right stick up: Scroll up -- Right stick left: Scroll left -- Right stick right: Scroll right -- Right stick down: Scroll down - -But they can be customized via the `shortcuts` parameter. It is not limited to the intents -above. Any class that inherits from the `Intent` base class can be used as the emitted intent -for a gamepad activator (button or axis). +### Callbacks +You can provide a `onBeforeIntent` method to `GamepadControl` to intercept just before an intent +would be invoked on the primary focus of your Flutter app. -#### Multiple GamepadControl widgets - -Note that if you have multiple `GamepadControl` widgets concurrently in your widget tree, they -will all emit intents on the `primaryFocus` focus node. Except if you set `ignoreEvents` or -use `onBeforeIntent` to block intents from all but one of the `GamepadControl` widgets. - -The `GamepadControl` widget does not check if primaryFocus is a descendant of itself. +If you return false from it, the intent is blocked from being emitted. +```dart +bool onBeforeIntent(GamepadActivator activator, Intent intent) { -### GamepadInterceptor +} +``` -If you want to intercept a Gamepad intent locally next to a Widget you can do so with -`GamepadInterceptor`. Its `onBeforeIntent` is only called if a descendant widget has focus. +However with local states this becomes impractical and ***flutter_gamepads*** provides an +other widget `GamepadInterceptor` that you wrap a subtree of widgets. It's only purpose +is to provide an onBeforeIntent callback that is locally scoped. ```dart GamepadInterceptor( onBeforeIntent: (activator, intent) { - if (intent is ScrollIntent) { - // Handle scroll intent - print('Gamepad scroll ${intent.direction}'); - - // Block actual emit of ScrollIntent - return false; - } - // Allow other intents such as focus change to occur - return true; - } + + }, child: YourWidget(), ) ``` -An example of how to build a gamepad extended widget can be found in -[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). - - -### Callbacks and the emit chain -The chain from received `NormalizedGamepadEvent` from `gamepads` package via callbacks to -emitting an intent is described by the diagram below. +#### Example -![Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) - -If no GamepadInterceptor is found, or if GamepadControl.onBeforeIntent is not set, execution -continues as if true was returned. +An example of how to build a gamepad-extended widget can be found in +[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). ### Blocking Gamepad input @@ -159,7 +120,56 @@ Method 1 and 2 are good for when you fully want to block gamepad control of Flut Method 3 or 4 is good if you want to block specific intents. -## Flame specific guidance +### Multiple GamepadControl widgets + +Note that if you have multiple `GamepadControl` widgets concurrently in your widget tree, they +will all emit intents on the `primaryFocus` focus node. Except if you set `ignoreEvents` or +use `onBeforeIntent` to block intents from all but one of the `GamepadControl` widgets. + +The `GamepadControl` widget does not check if primaryFocus is a descendant of itself. + + +### How it works + +`GamepadControl` listens on `NormalizedGamepadEvent` from ***gamepads*** package and maps those +to a `GamepadActivator` and its related `Intent`. + +Input repetition is conceptually started on activation of a GamepadActivator and stopped +once the activator has been canceled (eg. button up or axis below minimum threshold). + +`GamepadControl` will lookup the closest ancestor `GamepadInterceptor` from `primaryFocus` +and call its `onBeforeIntent` first (if there is one), and then proceed to `onBeforeIntent` on +`GamepadControl`. Calling is lazy so if the local `onBeforeIntent` returns fall, the one on +`GamepadControl` is not called. + +If no onBeforeIntent has rejected, the Intent will be invoked on the primary focus context. + +[Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) + + +### Defaults + +By default `GamepadControl` comes with these bindings: + +- D-pad up: Previous focus +- D-pad left: Previous focus +- D-pad right: Next focus +- D-pad down: Next focus +- A: Activate +- B: Dismiss +- Right stick up: Scroll up +- Right stick left: Scroll left +- Right stick right: Scroll right +- Right stick down: Scroll down + +Except for Activate and Dismiss, all intents have input repeat enabled by default. + +The bindings can customized via the `shortcuts` parameter. It is not limited to the intents +above. Any class that inherits from the `Intent` base class can be used as the emitted intent +for a gamepad activator (button or axis). + + +### Flame specific guidance `flutter_gamepads` can be helpful in scenarios when you have overlays in your Flame game that you want users to be able to navigate with their gamepad. From c4c7ba445535c59082e054c6e7fa9fab1a589e70 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:20:56 +0200 Subject: [PATCH 57/65] docs: tweak --- packages/flutter_gamepads/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 4d2a1b9fb..3c3fd0109 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -62,7 +62,7 @@ example the border of focused widgets stand out in a different color. detailed explanation of callbacks. -## API Usage +## Usage `GamepadControl` is the main widget of this package. You usually have exactly one of this widget that wraps your MaterialApp or similar. From b09f376747d25182dd5ec2870fda3e759eaa6d92 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:23:44 +0200 Subject: [PATCH 58/65] docs: tweak --- packages/flutter_gamepads/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 3c3fd0109..2c5b4c637 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -65,7 +65,8 @@ example the border of focused widgets stand out in a different color. ## Usage `GamepadControl` is the main widget of this package. You usually have exactly one of this widget -that wraps your MaterialApp or similar. +that wraps your MaterialApp or similar. This widget will listen on ***gamepads*** input stream +and emit Flutter intents on the primary focused context based on user input. ### Callbacks From 1bd4928f29ca64051e67c765efcec2666ae2f41d Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:25:50 +0200 Subject: [PATCH 59/65] docs: Fix italics --- packages/flutter_gamepads/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 2c5b4c637..719031eec 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -19,7 +19,7 @@ of user input. - (actually anything that you can do with Intents in Flutter, plus more with callbacks) - Gamepad buttons and axes can be used as input - Input repetition (on long press/activation) -- Uses [gamepads](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms +- Uses [*gamepads*](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms support library - A GamepadControl widget to wrap your app which in some cases is all you need. - Callbacks that allow intercepting an Intent before it actually is emitted. @@ -65,7 +65,7 @@ example the border of focused widgets stand out in a different color. ## Usage `GamepadControl` is the main widget of this package. You usually have exactly one of this widget -that wraps your MaterialApp or similar. This widget will listen on ***gamepads*** input stream +that wraps your MaterialApp or similar. This widget will listen on *gamepads* input stream and emit Flutter intents on the primary focused context based on user input. @@ -82,7 +82,7 @@ bool onBeforeIntent(GamepadActivator activator, Intent intent) { } ``` -However with local states this becomes impractical and ***flutter_gamepads*** provides an +However with local states this becomes impractical and *flutter_gamepads* provides an other widget `GamepadInterceptor` that you wrap a subtree of widgets. It's only purpose is to provide an onBeforeIntent callback that is locally scoped. @@ -109,7 +109,7 @@ There are four ways to block gamepad input from invoking intents: 1. Omitting the `GamepadControl` widget from your widget tree - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. 2. `GamepadControl.ignoreEvents == true` - - Early check on each `gamepads` event, axis activation memory is reset and repeat timers are + - Early check on each *gamepads* event, axis activation memory is reset and repeat timers are reset. 3. `GamepadInterceptor.onBeforeIntent() => false` - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() @@ -132,7 +132,7 @@ The `GamepadControl` widget does not check if primaryFocus is a descendant of it ### How it works -`GamepadControl` listens on `NormalizedGamepadEvent` from ***gamepads*** package and maps those +`GamepadControl` listens on `NormalizedGamepadEvent` from *gamepads* package and maps those to a `GamepadActivator` and its related `Intent`. Input repetition is conceptually started on activation of a GamepadActivator and stopped From cb7e80b73240366dd7ff385b2cfd77465f59595e Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:34:25 +0200 Subject: [PATCH 60/65] docs: provide more visual anchor in Examples heading --- packages/flutter_gamepads/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 719031eec..4977f850c 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -96,11 +96,13 @@ GamepadInterceptor( ``` -#### Example +#### onBeforeIntent examples An example of how to build a gamepad-extended widget can be found in [SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). +Another example, using the activator to support 4-way directional D-pad +within a Tick-tac-toe game is in [TicTacToe widget](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/game_page.dart). ### Blocking Gamepad input From e8f003167366dc76f4193ab65ac4f6c3cb1cb04b Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:42:07 +0200 Subject: [PATCH 61/65] docs: Update package description in pubspec.yaml --- packages/flutter_gamepads/pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter_gamepads/pubspec.yaml b/packages/flutter_gamepads/pubspec.yaml index 1b05c143c..e5be033bf 100644 --- a/packages/flutter_gamepads/pubspec.yaml +++ b/packages/flutter_gamepads/pubspec.yaml @@ -1,9 +1,9 @@ name: flutter_gamepads resolution: workspace -description: A Flutter plugin to handle gamepad input across multiple platforms. +description: A Flutter package that maps gamepad input to UI interaction. version: 0.1.10 homepage: https://github.com/flame-engine/gamepads -repository: https://github.com/flame-engine/gamepads/tree/main/packages/gamepads +repository: https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads flutter: From a5a403f18d19d2abf94bdb564abc8422cb473f8a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:43:39 +0200 Subject: [PATCH 62/65] docs: Fix package LISENCE file --- packages/flutter_gamepads/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_gamepads/LICENSE b/packages/flutter_gamepads/LICENSE index ba75c69f7..fd50777c3 100644 --- a/packages/flutter_gamepads/LICENSE +++ b/packages/flutter_gamepads/LICENSE @@ -1 +1 @@ -TODO: Add your license here. +../../LICENSE From 4e0bae87a318ff24f944c168f687ba8729322246 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:45:11 +0200 Subject: [PATCH 63/65] docs: Add H1 in CHANGELOG.md --- packages/flutter_gamepads/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/flutter_gamepads/CHANGELOG.md b/packages/flutter_gamepads/CHANGELOG.md index c52abc3de..dd3f87128 100644 --- a/packages/flutter_gamepads/CHANGELOG.md +++ b/packages/flutter_gamepads/CHANGELOG.md @@ -1,3 +1,5 @@ +# Change Log + ## 0.1.0 - Initial release From c6cb2acccbdfa9efd93036e52f93d29c0de60a9a Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 11:47:30 +0200 Subject: [PATCH 64/65] chore: markdown format --- packages/flutter_gamepads/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 4977f850c..83ffeab82 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -104,6 +104,7 @@ An example of how to build a gamepad-extended widget can be found in Another example, using the activator to support 4-way directional D-pad within a Tick-tac-toe game is in [TicTacToe widget](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/game_page.dart). + ### Blocking Gamepad input There are four ways to block gamepad input from invoking intents: From e2a836a1ca3c20efb5295748707c365f58b396e0 Mon Sep 17 00:00:00 2001 From: Lisette Linse Date: Fri, 3 Apr 2026 12:18:18 +0200 Subject: [PATCH 65/65] docs: Fix links in README.md --- packages/flutter_gamepads/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md index 83ffeab82..2efc3f470 100644 --- a/packages/flutter_gamepads/README.md +++ b/packages/flutter_gamepads/README.md @@ -99,10 +99,10 @@ GamepadInterceptor( #### onBeforeIntent examples An example of how to build a gamepad-extended widget can be found in -[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). +[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). Another example, using the activator to support 4-way directional D-pad -within a Tick-tac-toe game is in [TicTacToe widget](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flutter_example/pages/game_page.dart). +within a Tick-tac-toe game is in [TicTacToe widget](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart). ### Blocking Gamepad input @@ -148,7 +148,7 @@ and call its `onBeforeIntent` first (if there is one), and then proceed to `onBe If no onBeforeIntent has rejected, the Intent will be invoked on the primary focus context. -[Diagram of the callbacks and intent emit chain](docs/input_diagram.svg) +[Diagram of the callbacks and intent emit chain](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/docs/input_diagram.svg) ### Defaults @@ -181,13 +181,13 @@ Flame game that you want users to be able to navigate with their gamepad. 1. Wrap your `GameWidget` with a `GamepadControl` widget 2. For overlays that represent a modal dialog, you will need to trap the focus in the dialog. See - [OverlayDialogBackdrop](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) + [OverlayDialogBackdrop](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) in Flame example app for how you can do that. In that example the dialog itself will receive the focus so that when a mouse user opens the dialog, it won't show a focus indicator on a button in the dialog. 3. To close overlay dialogs on DismissIntent, you will need to catch it with `onBeforeIntent` and close the overlay. In - [Flame Example](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_example/example/lib/flame_example/main.dart) + [Flame Example](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flame_example/main.dart) this is done generically at the root, but could instead wrap each dialog in a `GamepadInterceptor` to do it locally if you need to guard closing the dialog by some condition.