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) - Swipe left to demote an app to the bottom (swipe left again to restore)
- Long-press for Edit Tags, App Info, and more - 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. **Settings** — Toggle icon visibility, color mode, theme, sort order, auto-focus, status icons, and demoted app visibility from the menu.
## Download ## Download

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:device_apps/device_apps.dart'; import 'package:device_apps/device_apps.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -82,6 +84,12 @@ class AppLauncherScreen extends StatefulWidget {
class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver { class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver {
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode(); 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> apps = [];
List<AppInfo> filteredApps = []; List<AppInfo> filteredApps = [];
bool isLoading = true; bool isLoading = true;
@ -95,6 +103,11 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
_loadInstalledApps(); _loadInstalledApps();
_appsChangeSub = DeviceApps.listenToAppsChanges().listen((_) => _loadInstalledApps()); _appsChangeSub = DeviceApps.listenToAppsChanges().listen((_) => _loadInstalledApps());
searchController.addListener(_filterApps); searchController.addListener(_filterApps);
searchFocusNode.addListener(() {
if (searchFocusNode.hasFocus && _selectedIndex != null) {
setState(() => _selectedIndex = null);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.read<AppSettings>().autoFocus) { if (context.read<AppSettings>().autoFocus) {
FocusScope.of(context).requestFocus(searchFocusNode); FocusScope.of(context).requestFocus(searchFocusNode);
@ -106,6 +119,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_appsChangeSub?.cancel(); _appsChangeSub?.cancel();
_dpadPress.cancel();
_navFocusNode.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@ -231,32 +247,44 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
void _showContextMenu(BuildContext context, AppInfo app) { void _showContextMenu(BuildContext context, AppInfo app) {
final dpadPress = DpadPress();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) { builder: (context) {
return Column( // Handles center presses here so holding it closes the menu instead of repeatedly activating an item.
mainAxisSize: MainAxisSize.min, return Focus(
children: [ onKeyEvent: (node, event) {
ListTile( if (!isDpadSelect(event.logicalKey)) return KeyEventResult.ignored;
leading: Icon(Icons.edit), return dpadPress.handle(event, onPress: () {
title: Text('Edit Tags'), final focused = FocusManager.instance.primaryFocus?.context;
onTap: () { if (focused != null) Actions.maybeInvoke(focused, const ActivateIntent());
Navigator.pop(context); }, onLongPress: () => Navigator.pop(context));
_editAppTags(app); },
}, child: Column(
), mainAxisSize: MainAxisSize.min,
ListTile( children: [
leading: Icon(Icons.info_outline), ListTile(
title: Text('App Info'), autofocus: true,
onTap: () { leading: Icon(Icons.edit),
Navigator.pop(context); title: Text('Edit Tags'),
_openAppInfo(app); 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 { Future<void> _togglePin(AppInfo app) async {
@ -404,12 +432,131 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
return SizedBox.shrink(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus(
focusNode: _navFocusNode,
autofocus: true,
onKeyEvent: _handleKey,
child: _buildScaffold(context),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: TextField( title: TextField(
focusNode: context.read<AppSettings>().autoFocus ? searchFocusNode : null, focusNode: searchFocusNode,
controller: searchController, controller: searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search apps...', hintText: 'Search apps...',
@ -427,6 +574,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
}, },
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
key: _menuKey,
enabled: searchController.text.isEmpty, enabled: searchController.text.isEmpty,
icon: SvgPicture.asset( icon: SvgPicture.asset(
"assets/plasmoid.svg", "assets/plasmoid.svg",
@ -457,7 +605,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
break; break;
case 'Toggle AutoFocus': case 'Toggle AutoFocus':
settings.updateSetting('autoFocus', !settings.autoFocus); settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) FocusScope.of(context).unfocus(); if (!settings.autoFocus) _navFocusNode.requestFocus();
break; break;
case 'Toggle StatusIcons': case 'Toggle StatusIcons':
settings.updateSetting('showStatusIcons', !settings.showStatusIcons); settings.updateSetting('showStatusIcons', !settings.showStatusIcons);
@ -554,6 +702,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
body: isLoading body: isLoading
? Center(child: CircularProgressIndicator()) ? Center(child: CircularProgressIndicator())
: ListView.builder( : ListView.builder(
controller: _scrollController,
itemCount: filteredApps.length, itemCount: filteredApps.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final app = filteredApps[index]; final app = filteredApps[index];
@ -583,21 +732,26 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
color: Colors.white, color: Colors.white,
), ),
), ),
child: ListTile( child: KeyedSubtree(
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null, key: index == _selectedIndex ? _selectedKey : null,
title: Text(app.name), child: ListTile(
subtitle: context.read<AppSettings>().showTags selected: index == _selectedIndex,
? RichText( selectedTileColor: Theme.of(context).focusColor,
text: TextSpan( leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
style: DefaultTextStyle.of(context).style, title: Text(app.name),
children: _formatTags(app.tags.join(' ')), subtitle: context.read<AppSettings>().showTags
), ? RichText(
overflow: TextOverflow.ellipsis, text: TextSpan(
) style: DefaultTextStyle.of(context).style,
: null, children: _formatTags(app.tags.join(' ')),
trailing: _buildTrailingIcon(app), ),
onTap: () => _launchApp(app), overflow: TextOverflow.ellipsis,
onLongPress: () => _showContextMenu(context, app), )
: 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 = """ String aboutContent = """
**Plasmoid** is a minimalist and opinionated app launcher for Android. **Plasmoid** is a minimalist and opinionated app launcher for Android.

View file

@ -2,7 +2,7 @@ name: plasmoid
description: "A new Flutter project." description: "A new Flutter project."
publish_to: 'none' # Remove this line if you wish to publish to pub.dev 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: environment:
sdk: ^3.6.0 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:plasmoid/main.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() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { final launched = <String>[];
// Build our app and trigger a frame. var installed = ['Alpha', 'Bravo', 'Charlie'];
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0. setUp(() async {
expect(find.text('0'), findsOneWidget); launched.clear();
expect(find.text('1'), findsNothing); 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. Future<void> pumpLauncher(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.add)); await tester.pumpWidget(ChangeNotifierProvider(
await tester.pump(); create: (_) => AppSettings(),
child: MaterialApp(home: AppLauncherScreen()),
));
await tester.pumpAndSettle();
}
// Verify that our counter has incremented. Future<void> press(WidgetTester tester, LogicalKeyboardKey key) async {
expect(find.text('0'), findsNothing); await tester.sendKeyEvent(key);
expect(find.text('1'), findsOneWidget); 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);
}); });
} }