plasmoid/lib/main.dart

819 lines
28 KiB
Dart

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';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:plasmoid/preferences.dart';
import 'package:plasmoid/read_only.dart';
import 'package:plasmoid/settings.dart';
import 'package:provider/provider.dart';
import 'day.dart';
import 'night.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SharedPreferencesService().initialize();
runApp(
ChangeNotifierProvider(
create: (_) => AppSettings(),
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AppSettings>(
builder: (context, settings, child) {
return MaterialApp(
title: 'Plasmoid Launcher',
theme: ThemeData(
brightness: Brightness.light,
scaffoldBackgroundColor: Color(0xffF6F4F1),
appBarTheme: AppBarTheme(backgroundColor: Color(0xffF6F4F1)),
textTheme: GoogleFonts.ibmPlexMonoTextTheme(dayText),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: Color(0xFF121212),
appBarTheme: AppBarTheme(backgroundColor: Color(0xFF121212)),
textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText),
),
themeMode: settings.darkTheme ? ThemeMode.dark : ThemeMode.light,
home: AppLauncherScreen(),
);
},
);
}
}
class AppInfo {
final Uint8List? icon;
final String name;
final String packageName;
final List<String> tags;
DateTime? lastUsed;
bool isPinned;
bool isDemoted;
AppInfo({
this.icon,
required this.name,
required this.packageName,
required this.tags,
this.lastUsed,
this.isPinned = false,
this.isDemoted = false,
});
}
class AppLauncherScreen extends StatefulWidget {
const AppLauncherScreen({super.key});
@override
State<AppLauncherScreen> createState() => _AppLauncherScreenState();
}
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;
bool _loadingApps = false;
StreamSubscription<ApplicationEvent>? _appsChangeSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_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);
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appsChangeSub?.cancel();
_dpadPress.cancel();
_navFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_loadInstalledApps();
}
}
Future<void> _loadInstalledApps() async {
if (_loadingApps) return;
_loadingApps = true;
try {
List<Application> installedApps = await DeviceApps.getInstalledApplications(
includeAppIcons: true,
onlyAppsWithLaunchIntent: true,
includeSystemApps: true,
);
if (!mounted) return;
List<AppInfo> loadedApps = installedApps.map((app) {
final lastUsedMillis = SharedPreferencesService().getInt("lastUsed_${app.packageName}");
final storedTags = SharedPreferencesService().getString("tags_${app.packageName}");
final isPinned = SharedPreferencesService().getBool("isPinned_${app.packageName}", false);
final isDemoted = SharedPreferencesService().getBool("isDemoted_${app.packageName}", false);
List<String> tags = [];
if (storedTags != null && storedTags.trim().isNotEmpty) {
tags = storedTags.split(" ");
} else {
String categoryStr = app.category.toString().split('.').last;
if (categoryStr.toLowerCase() != 'undefined') {
tags.add('@${categoryStr.toLowerCase()}');
}
}
return AppInfo(
icon: app is ApplicationWithIcon ? app.icon : null,
name: app.appName,
packageName: app.packageName,
tags: tags,
lastUsed: lastUsedMillis != null ? DateTime.fromMillisecondsSinceEpoch(lastUsedMillis) : null,
isPinned: isPinned,
isDemoted: isDemoted,
);
}).toList();
setState(() {
apps = loadedApps;
isLoading = false;
});
_filterApps();
} finally {
_loadingApps = false;
}
}
void _filterApps() {
String query = searchController.text.toLowerCase();
setState(() {
final hideDemoted = context.read<AppSettings>().hideDemoted;
filteredApps = apps.where((app) {
if (hideDemoted && app.isDemoted) return false;
bool matchesName = app.name.toLowerCase().contains(query);
bool matchesTag = app.tags.any((tag) => tag.toLowerCase().contains(query));
return matchesName || matchesTag;
}).toList();
// Sort pinned apps first, then by last used or alphabetically, then demoted apps
final sortLastUsed = context.read<AppSettings>().sortLastUsed;
filteredApps.sort((a, b) {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
if (a.isDemoted && !b.isDemoted) return 1;
if (!a.isDemoted && b.isDemoted) return -1;
if (sortLastUsed) {
DateTime aTime = a.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
DateTime bTime = b.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
final timeCompare = bTime.compareTo(aTime);
if (timeCompare != 0) return timeCompare;
}
return a.name.compareTo(b.name);
});
});
}
Future<void> _launchApp(AppInfo app) async {
bool launched = await DeviceApps.openApp(app.packageName);
if (!mounted) return;
if (launched) {
DateTime now = DateTime.now();
await SharedPreferencesService().setInt("lastUsed_${app.packageName}", now.millisecondsSinceEpoch);
setState(() { app.lastUsed = now; });
_filterApps();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name}')),
);
}
}
Future<bool> _handleDismiss(DismissDirection direction, AppInfo app) async {
if (direction == DismissDirection.startToEnd) {
await (app.isDemoted ? _toggleDemote(app) : _togglePin(app));
} else if (direction == DismissDirection.endToStart) {
await (app.isPinned ? _togglePin(app) : _toggleDemote(app));
}
return false;
}
Future<void> _openAppInfo(AppInfo app) async {
bool launched = await DeviceApps.openAppSettings(app.packageName);
if (!mounted) return;
if (!launched) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name} Settings')),
);
}
}
void _showContextMenu(BuildContext context, AppInfo app) {
final dpadPress = DpadPress();
showModalBottomSheet(
context: context,
builder: (context) {
// 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 {
setState(() {
app.isPinned = !app.isPinned;
if (app.isPinned) {
app.isDemoted = false;
}
});
await SharedPreferencesService().setBool("isPinned_${app.packageName}", app.isPinned);
if (app.isPinned) {
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", false);
}
_filterApps();
}
Future<void> _toggleDemote(AppInfo app) async {
setState(() {
app.isDemoted = !app.isDemoted;
if (app.isDemoted) {
app.isPinned = false;
}
});
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", app.isDemoted);
if (app.isDemoted) {
await SharedPreferencesService().setBool("isPinned_${app.packageName}", false);
}
_filterApps();
}
void _editAppTags(AppInfo app) {
TextEditingController tagController = TextEditingController(text: app.tags.join(" "));
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Edit Tags for ${app.name}'),
content: TextField(
controller: tagController,
decoration: InputDecoration(
hintText: 'Enter tags (e.g., #general, @context, +project)',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
TextButton(
onPressed: () async {
setState(() {
app.tags
..clear()
..addAll(tagController.text.split(" ").where((t) => t.isNotEmpty));
});
await SharedPreferencesService().setString("tags_${app.packageName}", app.tags.join(" "));
if (!mounted) return;
Navigator.of(context).pop();
_filterApps();
},
child: Text('Save'),
),
],
),
);
}
List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
final Set<String> tagSet = {};
for (final match in tagPattern.allMatches(content)) {
tagSet.add(match.group(0)!);
}
final sortedTags = tagSet.toList()
..sort((a, b) {
if (a.startsWith('+') && !b.startsWith('+')) return -1;
if (b.startsWith('+') && !a.startsWith('+')) return 1;
if (a.startsWith('@') && !b.startsWith('@')) return -1;
if (b.startsWith('@') && !a.startsWith('@')) return 1;
return a.compareTo(b);
});
return sortedTags.map((tag) {
Color tagColor = Colors.white;
if (tag.startsWith('#')) tagColor = Colors.pinkAccent;
if (tag.startsWith('+')) tagColor = Colors.cyan;
if (tag.startsWith('@')) tagColor = Colors.orange;
return TextSpan(
text: "$tag ",
style: TextStyle(color: tagColor, fontWeight: FontWeight.bold),
);
}).toList();
}
Widget _buildAppIcon(AppInfo app) {
Widget iconWidget;
if (app.icon != null) {
iconWidget = Image.memory(
app.icon!,
width: 40,
height: 40,
fit: BoxFit.contain,
);
} else {
iconWidget = Icon(Icons.apps, size: 40);
}
if (!context.read<AppSettings>().colorIcons) {
iconWidget = ColorFiltered(
colorFilter: ColorFilter.matrix(<double>[
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]),
child: iconWidget,
);
}
return iconWidget;
}
void _showInfoPage(BuildContext context, String title, String content) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ReadOnlyPage(
title: title,
content: content,
isDarkMode: context.read<AppSettings>().darkTheme,
),
),
);
}
Widget _buildTrailingIcon(AppInfo app) {
if (!context.read<AppSettings>().showStatusIcons) return SizedBox.shrink();
if (app.isPinned) {
return Icon(Icons.push_pin, color: Colors.grey);
} else if (app.isDemoted) {
return Icon(Icons.arrow_downward, color: Colors.grey);
}
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: searchFocusNode,
controller: searchController,
decoration: InputDecoration(
hintText: 'Search apps...',
border: InputBorder.none,
),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: GestureDetector(
onTap: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
}
},
child: PopupMenuButton<String>(
key: _menuKey,
enabled: searchController.text.isEmpty,
icon: SvgPicture.asset(
"assets/plasmoid.svg",
width: 30,
colorFilter: const ColorFilter.mode(Color(0xff38818a), BlendMode.srcIn),
),
onSelected: (String value) async {
final settings = context.read<AppSettings>();
switch (value) {
case 'Refresh':
_loadInstalledApps();
break;
case 'Toggle Tags':
settings.updateSetting('showTags', !settings.showTags);
break;
case 'Toggle Icons':
settings.updateSetting('showIcons', !settings.showIcons);
break;
case 'Toggle Color Icons':
settings.updateSetting('colorIcons', !settings.colorIcons);
break;
case 'Toggle Theme':
settings.updateSetting('darkTheme', !settings.darkTheme);
break;
case 'Toggle Sorting':
settings.updateSetting('sortLastUsed', !settings.sortLastUsed);
_filterApps();
break;
case 'Toggle AutoFocus':
settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) _navFocusNode.requestFocus();
break;
case 'Toggle StatusIcons':
settings.updateSetting('showStatusIcons', !settings.showStatusIcons);
break;
case 'Toggle HideDemoted':
settings.updateSetting('hideDemoted', !settings.hideDemoted);
_filterApps();
break;
case 'About':
_showInfoPage(context, "About", aboutContent);
break;
}
},
itemBuilder: (BuildContext context) {
final settings = context.read<AppSettings>();
return [
PopupMenuItem<String>(
value: 'Refresh',
child: ListTile(
leading: Icon(Icons.refresh),
title: Text('Refresh App List'),
),
),
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading: Icon(settings.showTags ? Icons.short_text : Icons.tag),
title: Text(settings.showTags ? 'Hide Tags' : 'Show Tags'),
),
),
PopupMenuItem<String>(
value: 'Toggle Icons',
child: ListTile(
leading: Icon(settings.showIcons ? Icons.apps : Icons.apps_outlined),
title: Text(settings.showIcons ? 'Hide Icons' : 'Show Icons'),
),
),
PopupMenuItem<String>(
value: 'Toggle StatusIcons',
child: ListTile(
leading: Icon(settings.showStatusIcons ? Icons.label_off_outlined : Icons.label_outlined),
title: Text(settings.showStatusIcons ? 'Hide Status Icons' : 'Show Status Icons'),
),
),
PopupMenuItem<String>(
value: 'Toggle HideDemoted',
child: ListTile(
leading: Icon(settings.hideDemoted ? Icons.visibility : Icons.visibility_off),
title: Text(settings.hideDemoted ? 'Show Demoted Apps' : 'Hide Demoted Apps'),
),
),
PopupMenuItem<String>(
value: 'Toggle Color Icons',
child: ListTile(
leading: Icon(Icons.palette),
title: Text(settings.colorIcons ? 'Use Monochrome Icons' : 'Use Color Icons'),
),
),
PopupMenuItem<String>(
value: 'Toggle Theme',
child: ListTile(
leading: Icon(settings.darkTheme ? Icons.light_mode : Icons.dark_mode),
title: Text(settings.darkTheme ? 'Switch to Light Mode' : 'Switch to Dark Mode'),
),
),
PopupMenuItem<String>(
value: 'Toggle Sorting',
child: ListTile(
leading: Icon(settings.sortLastUsed ? Icons.access_time : Icons.sort_by_alpha),
title: Text(settings.sortLastUsed ? 'Sort Alphabetically' : 'Sort by Last Used'),
),
),
PopupMenuItem<String>(
value: 'Toggle AutoFocus',
child: ListTile(
leading: Icon(Icons.center_focus_strong),
title: Text(settings.autoFocus ? 'Disable Auto Focus' : 'Enable Auto Focus'),
),
),
PopupMenuItem<String>(
value: 'About',
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
),
),
];
},
),
),
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scrollController,
itemCount: filteredApps.length,
itemBuilder: (context, index) {
final app = filteredApps[index];
return Dismissible(
key: Key(app.packageName),
direction: app.isPinned
? DismissDirection.endToStart
: app.isDemoted
? DismissDirection.startToEnd
: DismissDirection.horizontal,
confirmDismiss: (direction) => _handleDismiss(direction, app),
background: Container(
color: app.isDemoted ? Colors.grey : Colors.teal,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 20.0),
child: Icon(
app.isDemoted ? Icons.arrow_upward : Icons.push_pin,
color: Colors.white,
),
),
secondaryBackground: Container(
color: app.isPinned ? Colors.grey : Colors.deepOrange,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20.0),
child: Icon(
app.isPinned ? Icons.push_pin_outlined : Icons.arrow_downward,
color: Colors.white,
),
),
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),
),
),
);
},
)
);
}
}
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.
## Search & Launch:
Open Plasmoid and start typing in the search bar to filter your apps and shared files by name or tag.
Tap an app to launch it immediately.
## Tagging:
Long-press an app and choose **Edit Tags** to assign tags.
Tags help you organize items and are searchable using prefixes like @context, #general, or +project.
## Gestures & Settings:
Swipe right on an app to pin it to the top of the list (swipe right again to unpin).
Swipe left to demote it to the bottom (swipe left again to restore).
Long-press an app to edit its tags or open its system settings page.
Use the menu button (plasmoid) to toggle settings such as icon visibility, color mode, theme, sorting (alphabetically or by last used), and auto-focus.
---
Made with 💚 by randogoth
Icon by [Game Icons.net](https://game-icons.net/).
""";