feat: add D-pad navigation and bump version to 1.1.0

This commit is contained in:
randogoth 2026-10-13 12:18:01 +03:00
parent 5208772c20
commit 2b6976ca72
4 changed files with 388 additions and 59 deletions

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter/material.dart';
import 'package:device_apps/device_apps.dart';
import 'package:flutter/services.dart';
@ -82,6 +84,12 @@ class AppLauncherScreen extends StatefulWidget {
class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver {
final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
final FocusNode _navFocusNode = FocusNode(debugLabel: 'D-pad navigation');
final GlobalKey<PopupMenuButtonState<String>> _menuKey = GlobalKey();
final GlobalKey _selectedKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
final DpadPress _dpadPress = DpadPress();
int? _selectedIndex;
List<AppInfo> apps = [];
List<AppInfo> filteredApps = [];
bool isLoading = true;
@ -95,6 +103,11 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
_loadInstalledApps();
_appsChangeSub = DeviceApps.listenToAppsChanges().listen((_) => _loadInstalledApps());
searchController.addListener(_filterApps);
searchFocusNode.addListener(() {
if (searchFocusNode.hasFocus && _selectedIndex != null) {
setState(() => _selectedIndex = null);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.read<AppSettings>().autoFocus) {
FocusScope.of(context).requestFocus(searchFocusNode);
@ -106,6 +119,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appsChangeSub?.cancel();
_dpadPress.cancel();
_navFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@ -231,32 +247,44 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
void _showContextMenu(BuildContext context, AppInfo app) {
final dpadPress = DpadPress();
showModalBottomSheet(
context: context,
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.edit),
title: Text('Edit Tags'),
onTap: () {
Navigator.pop(context);
_editAppTags(app);
},
),
ListTile(
leading: Icon(Icons.info_outline),
title: Text('App Info'),
onTap: () {
Navigator.pop(context);
_openAppInfo(app);
},
),
],
// Handles center presses here so holding it closes the menu instead of repeatedly activating an item.
return Focus(
onKeyEvent: (node, event) {
if (!isDpadSelect(event.logicalKey)) return KeyEventResult.ignored;
return dpadPress.handle(event, onPress: () {
final focused = FocusManager.instance.primaryFocus?.context;
if (focused != null) Actions.maybeInvoke(focused, const ActivateIntent());
}, onLongPress: () => Navigator.pop(context));
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
autofocus: true,
leading: Icon(Icons.edit),
title: Text('Edit Tags'),
onTap: () {
Navigator.pop(context);
_editAppTags(app);
},
),
ListTile(
leading: Icon(Icons.info_outline),
title: Text('App Info'),
onTap: () {
Navigator.pop(context);
_openAppInfo(app);
},
),
],
),
);
},
);
).whenComplete(dpadPress.cancel);
}
Future<void> _togglePin(AppInfo app) async {
@ -404,12 +432,131 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
return SizedBox.shrink();
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (searchFocusNode.hasFocus) {
if (key == LogicalKeyboardKey.arrowDown) {
if (event is KeyDownEvent && filteredApps.isNotEmpty) _enterList();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
if (event is KeyDownEvent) _tapMenuButton();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.arrowUp) {
if (event is! KeyUpEvent) _moveSelection(key == LogicalKeyboardKey.arrowDown ? 1 : -1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
return _dpadPress.handle(event, onPress: searchFocusNode.requestFocus, onLongPress: () => _moveSelectedApp(promote: true));
}
if (key == LogicalKeyboardKey.arrowRight) {
return _dpadPress.handle(event, onPress: _openMenu, onLongPress: () => _moveSelectedApp(promote: false));
}
if (isDpadSelect(key)) {
return _dpadPress.handle(event, onPress: () {
final app = _selectedApp;
if (app != null) _launchApp(app);
}, onLongPress: () {
final app = _selectedApp;
if (app != null) _showContextMenu(context, app);
});
}
return KeyEventResult.ignored;
}
void _enterList() {
if (_scrollController.hasClients) _scrollController.jumpTo(0);
_navFocusNode.requestFocus();
_select(0);
}
void _moveSelection(int delta) {
if (filteredApps.isEmpty) return;
final index = ((_selectedIndex ?? -1) + delta).clamp(0, filteredApps.length - 1);
_select(index, alignment: delta > 0
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
}
void _select(int index, {ScrollPositionAlignmentPolicy alignment = ScrollPositionAlignmentPolicy.explicit}) {
setState(() => _selectedIndex = index);
WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected(alignment, estimate: true));
}
void _revealSelected(ScrollPositionAlignmentPolicy alignment, {required bool estimate}) {
final itemContext = _selectedKey.currentContext;
if (itemContext != null) {
Scrollable.ensureVisible(itemContext, alignmentPolicy: alignment);
return;
}
// Lazily built items far off screen have no context yet, so jump near them first.
final index = _selectedIndex;
if (!estimate || index == null || !_scrollController.hasClients || filteredApps.isEmpty) return;
final position = _scrollController.position;
final itemExtent = (position.maxScrollExtent + position.viewportDimension) / filteredApps.length;
_scrollController.jumpTo((index * itemExtent).clamp(0, position.maxScrollExtent));
WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected(alignment, estimate: false));
}
AppInfo? get _selectedApp {
final index = _selectedIndex;
return index != null && index < filteredApps.length ? filteredApps[index] : null;
}
Future<void> _moveSelectedApp({required bool promote}) async {
final index = _selectedIndex;
final app = _selectedApp;
if (index == null || app == null) return;
if (promote ? app.isPinned : app.isDemoted) return;
await _handleDismiss(promote ? DismissDirection.startToEnd : DismissDirection.endToStart, app);
if (!mounted) return;
if (filteredApps.isEmpty) {
setState(() => _selectedIndex = null);
return;
}
final newIndex = filteredApps.indexOf(app);
_select(newIndex >= 0 ? newIndex : min(index, filteredApps.length - 1));
}
void _tapMenuButton() {
if (searchController.text.isNotEmpty) {
searchController.clear();
} else {
_openMenu();
}
}
void _openMenu() {
// The menu route only focuses its own scope, so step into the first item once that happens.
void focusFirstItem() {
final scope = FocusManager.instance.primaryFocus;
if (scope is! FocusScopeNode) return;
FocusManager.instance.removeListener(focusFirstItem);
scope.focusInDirection(TraversalDirection.down);
}
FocusManager.instance.addListener(focusFirstItem);
_menuKey.currentState?.showButtonMenu();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _navFocusNode,
autofocus: true,
onKeyEvent: _handleKey,
child: _buildScaffold(context),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
focusNode: context.read<AppSettings>().autoFocus ? searchFocusNode : null,
focusNode: searchFocusNode,
controller: searchController,
decoration: InputDecoration(
hintText: 'Search apps...',
@ -427,6 +574,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
},
child: PopupMenuButton<String>(
key: _menuKey,
enabled: searchController.text.isEmpty,
icon: SvgPicture.asset(
"assets/plasmoid.svg",
@ -457,7 +605,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
break;
case 'Toggle AutoFocus':
settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) FocusScope.of(context).unfocus();
if (!settings.autoFocus) _navFocusNode.requestFocus();
break;
case 'Toggle StatusIcons':
settings.updateSetting('showStatusIcons', !settings.showStatusIcons);
@ -554,6 +702,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
body: isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scrollController,
itemCount: filteredApps.length,
itemBuilder: (context, index) {
final app = filteredApps[index];
@ -583,21 +732,26 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
color: Colors.white,
),
),
child: ListTile(
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
title: Text(app.name),
subtitle: context.read<AppSettings>().showTags
? RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: _formatTags(app.tags.join(' ')),
),
overflow: TextOverflow.ellipsis,
)
: null,
trailing: _buildTrailingIcon(app),
onTap: () => _launchApp(app),
onLongPress: () => _showContextMenu(context, app),
child: KeyedSubtree(
key: index == _selectedIndex ? _selectedKey : null,
child: ListTile(
selected: index == _selectedIndex,
selectedTileColor: Theme.of(context).focusColor,
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
title: Text(app.name),
subtitle: context.read<AppSettings>().showTags
? RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: _formatTags(app.tags.join(' ')),
),
overflow: TextOverflow.ellipsis,
)
: null,
trailing: _buildTrailingIcon(app),
onTap: () => _launchApp(app),
onLongPress: () => _showContextMenu(context, app),
),
),
);
},
@ -606,6 +760,37 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
}
bool isDpadSelect(LogicalKeyboardKey key) =>
key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter;
/// Tells short and long presses of a D-pad key apart, since key events carry no press duration.
class DpadPress {
LogicalKeyboardKey? _key;
Timer? _timer;
/// Runs [onPress] on release, or [onLongPress] once the key is held past the long-press timeout.
KeyEventResult handle(KeyEvent event, {required VoidCallback onPress, required VoidCallback onLongPress}) {
if (event is KeyDownEvent) {
cancel();
_key = event.logicalKey;
_timer = Timer(kLongPressTimeout, () {
_key = null;
onLongPress();
});
} else if (event is KeyUpEvent && _key == event.logicalKey) {
cancel();
onPress();
}
return KeyEventResult.handled;
}
void cancel() {
_timer?.cancel();
_timer = null;
_key = null;
}
}
String aboutContent = """
**Plasmoid** is a minimalist and opinionated app launcher for Android.