feat: add D-pad navigation and bump version to 1.1.0
This commit is contained in:
parent
5208772c20
commit
2b6976ca72
4 changed files with 388 additions and 59 deletions
261
lib/main.dart
261
lib/main.dart
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue