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

@ -27,6 +27,8 @@ A minimalist and opinionated app launcher for Android.
- Swipe left to demote an app to the bottom (swipe left again to restore)
- Long-press for Edit Tags, App Info, and more
**D-Pad** — From the search field, press down to close the keyboard and move into the list, or right to clear the search (or open the menu when it's empty). In the list, up and down move the selection, center launches, left returns to the search field, and right opens the menu. Hold left to promote an app (pin or restore it), hold right to demote it (unpin or move it to the bottom), and hold center to open or close the app menu.
**Settings** — Toggle icon visibility, color mode, theme, sort order, auto-focus, status icons, and demoted app visibility from the menu.
## Download

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.

View file

@ -2,7 +2,7 @@ name: plasmoid
description: "A new Flutter project."
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.1+2
version: 1.1.0+3
environment:
sdk: ^3.6.0

View file

@ -1,30 +1,172 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:plasmoid/main.dart';
import 'package:plasmoid/preferences.dart';
import 'package:plasmoid/settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Map<String, Object?> _app(String name) => {
'app_name': name,
'package_name': 'com.example.${name.toLowerCase()}',
'apk_file_path': '',
'version_name': '1',
'version_code': 1,
'data_dir': '',
'system_app': false,
'install_time': 0,
'update_time': 0,
'is_enabled': true,
'category': -1,
};
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
final launched = <String>[];
var installed = ['Alpha', 'Bravo', 'Charlie'];
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
setUp(() async {
launched.clear();
installed = ['Alpha', 'Bravo', 'Charlie'];
GoogleFonts.config.allowRuntimeFetching = false;
SharedPreferences.setMockInitialValues({'sortLastUsed': false});
await SharedPreferencesService().initialize();
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(const MethodChannel('g123k/device_apps'), (call) async {
switch (call.method) {
case 'getInstalledApps':
return installed.map(_app).toList();
case 'openApp':
launched.add((call.arguments as Map)['package_name'] as String);
return true;
}
return null;
});
messenger.setMockStreamHandler(const EventChannel('g123k/device_apps_events'), MockStreamHandler.inline(onListen: (_, __) {}));
});
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
Future<void> pumpLauncher(WidgetTester tester) async {
await tester.pumpWidget(ChangeNotifierProvider(
create: (_) => AppSettings(),
child: MaterialApp(home: AppLauncherScreen()),
));
await tester.pumpAndSettle();
}
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
Future<void> press(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyEvent(key);
await tester.pumpAndSettle();
}
Future<void> hold(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump(const Duration(seconds: 1));
await tester.sendKeyUpEvent(key);
await tester.pumpAndSettle();
}
ListTile tile(WidgetTester tester, String name) =>
tester.widget<ListTile>(find.ancestor(of: find.text(name), matching: find.byType(ListTile)));
EditableText searchField(WidgetTester tester) => tester.widget<EditableText>(find.byType(EditableText));
testWidgets('down enters the list and up/down move the selection', (tester) async {
await pumpLauncher(tester);
expect(searchField(tester).focusNode.hasFocus, isTrue);
await press(tester, LogicalKeyboardKey.arrowDown);
expect(searchField(tester).focusNode.hasFocus, isFalse);
expect(tile(tester, 'Alpha').selected, isTrue);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowDown);
expect(tile(tester, 'Charlie').selected, isTrue);
await press(tester, LogicalKeyboardKey.arrowUp);
expect(tile(tester, 'Bravo').selected, isTrue);
await press(tester, LogicalKeyboardKey.select);
expect(launched, ['com.example.bravo']);
});
testWidgets('left returns focus to the search field', (tester) async {
await pumpLauncher(tester);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowLeft);
expect(searchField(tester).focusNode.hasFocus, isTrue);
expect(tile(tester, 'Alpha').selected, isFalse);
});
testWidgets('right clears the search text, then opens and focuses the menu', (tester) async {
await pumpLauncher(tester);
await tester.enterText(find.byType(TextField), 'al');
await tester.pumpAndSettle();
await press(tester, LogicalKeyboardKey.arrowRight);
expect(searchField(tester).controller.text, isEmpty);
await press(tester, LogicalKeyboardKey.arrowRight);
expect(find.text('Refresh App List'), findsOneWidget);
final focused = FocusManager.instance.primaryFocus!.context!;
expect(find.descendant(of: find.byWidget(focused.widget), matching: find.text('Refresh App List')), findsOneWidget);
});
testWidgets('right in the list opens the menu', (tester) async {
await pumpLauncher(tester);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowRight);
expect(find.text('Refresh App List'), findsOneWidget);
});
testWidgets('long left pins and long right demotes the selected app', (tester) async {
await pumpLauncher(tester);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowDown);
await press(tester, LogicalKeyboardKey.arrowDown);
await hold(tester, LogicalKeyboardKey.arrowLeft);
expect(SharedPreferencesService().getBool('isPinned_com.example.charlie', false), isTrue);
expect(tile(tester, 'Charlie').selected, isTrue);
expect(searchField(tester).focusNode.hasFocus, isFalse);
await hold(tester, LogicalKeyboardKey.arrowRight);
expect(SharedPreferencesService().getBool('isPinned_com.example.charlie', false), isFalse);
await hold(tester, LogicalKeyboardKey.arrowRight);
expect(SharedPreferencesService().getBool('isDemoted_com.example.charlie', false), isTrue);
expect(tile(tester, 'Charlie').selected, isTrue);
expect(find.text('Refresh App List'), findsNothing);
});
testWidgets('long center toggles the context menu with focus on it', (tester) async {
await pumpLauncher(tester);
await press(tester, LogicalKeyboardKey.arrowDown);
await hold(tester, LogicalKeyboardKey.select);
expect(find.text('Edit Tags'), findsOneWidget);
expect(find.text('Edit Tags for Alpha'), findsNothing);
final focused = FocusManager.instance.primaryFocus!.context!;
expect(find.descendant(of: find.byWidget(focused.widget), matching: find.text('Edit Tags')), findsOneWidget);
await hold(tester, LogicalKeyboardKey.select);
expect(find.text('Edit Tags'), findsNothing);
expect(launched, isEmpty);
});
testWidgets('moving the selection scrolls it into view', (tester) async {
installed = [for (var i = 10; i < 70; i++) 'App$i'];
await pumpLauncher(tester);
await press(tester, LogicalKeyboardKey.arrowDown);
for (var i = 0; i < 40; i++) {
await press(tester, LogicalKeyboardKey.arrowDown);
}
expect(tile(tester, 'App50').selected, isTrue);
expect(tester.getRect(find.text('App50')).bottom, lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio));
await press(tester, LogicalKeyboardKey.arrowLeft);
await press(tester, LogicalKeyboardKey.arrowDown);
expect(tile(tester, 'App10').selected, isTrue);
});
}