better gestures, more settings, stability fixes

This commit is contained in:
randogoth 2026-06-19 13:32:48 +03:00
parent 7a1395220c
commit f005073ad5
4 changed files with 151 additions and 75 deletions

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:device_apps/device_apps.dart';
@ -74,8 +73,10 @@ class AppInfo {
}
class AppLauncherScreen extends StatefulWidget {
const AppLauncherScreen({super.key});
@override
_AppLauncherScreenState createState() => _AppLauncherScreenState();
State<AppLauncherScreen> createState() => _AppLauncherScreenState();
}
class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver {
@ -84,6 +85,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
List<AppInfo> apps = [];
List<AppInfo> filteredApps = [];
bool isLoading = true;
bool _loadingApps = false;
StreamSubscription<ApplicationEvent>? _appsChangeSub;
@override
@ -115,84 +117,93 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
Future<void> _loadInstalledApps() async {
List<Application> installedApps = await DeviceApps.getInstalledApplications(
includeAppIcons: true,
onlyAppsWithLaunchIntent: true,
includeSystemApps: true,
);
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 if (app.category != null) {
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,
if (_loadingApps) return;
_loadingApps = true;
try {
List<Application> installedApps = await DeviceApps.getInstalledApplications(
includeAppIcons: true,
onlyAppsWithLaunchIntent: true,
includeSystemApps: true,
);
}).toList();
setState(() {
apps = loadedApps;
filteredApps = apps;
isLoading = false;
});
_filterApps();
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 (context.read<AppSettings>().sortLastUsed) {
if (sortLastUsed) {
DateTime aTime = a.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
DateTime bTime = b.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
} else {
return a.name.compareTo(b.name);
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();
});
setState(() { app.lastUsed = now; });
_filterApps();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name}')),
@ -201,16 +212,17 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
Future<bool> _handleDismiss(DismissDirection direction, AppInfo app) async {
if (direction == DismissDirection.endToStart) {
_openAppInfo(app);
} else if (direction == DismissDirection.startToEnd) {
_editAppTags(app);
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')),
@ -226,19 +238,19 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(app.isPinned ? Icons.push_pin : Icons.push_pin_outlined),
title: Text(app.isPinned ? 'Unpin' : 'Pin'),
onTap: () async {
Navigator.pop(context); // Close the menu
await _togglePin(app);
leading: Icon(Icons.edit),
title: Text('Edit Tags'),
onTap: () {
Navigator.pop(context);
_editAppTags(app);
},
),
ListTile(
leading: Icon(app.isDemoted ? Icons.arrow_upward : Icons.arrow_downward),
title: Text(app.isDemoted ? 'Reset' : 'Demote'),
onTap: () async {
Navigator.pop(context); // Close the menu
await _toggleDemote(app);
leading: Icon(Icons.info_outline),
title: Text('App Info'),
onTap: () {
Navigator.pop(context);
_openAppInfo(app);
},
),
],
@ -255,6 +267,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
});
await SharedPreferencesService().setBool("isPinned_${app.packageName}", app.isPinned);
if (app.isPinned) { // pinning clears demote
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", false);
}
_filterApps();
}
@ -266,6 +281,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
});
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", app.isDemoted);
if (app.isDemoted) { // demoting clears pin
await SharedPreferencesService().setBool("isPinned_${app.packageName}", false);
}
_filterApps();
}
@ -290,6 +308,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
..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();
},
@ -376,12 +395,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
}
Widget _buildTrailingIcon(AppInfo app) {
if (!context.read<AppSettings>().showStatusIcons) return SizedBox.shrink();
if (app.isPinned) {
return Icon(Icons.push_pin, color: Colors.grey); // Pin icon for pinned apps
return Icon(Icons.push_pin, color: Colors.grey);
} else if (app.isDemoted) {
return Icon(Icons.disabled_by_default_outlined, color: Colors.grey); // Demote icon for demoted apps
return Icon(Icons.arrow_downward, color: Colors.grey);
}
return SizedBox.shrink(); // No icon for normal apps
return SizedBox.shrink();
}
@override
@ -416,6 +436,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
onSelected: (String value) async {
final settings = context.read<AppSettings>();
switch (value) {
case 'Refresh':
_loadInstalledApps();
break;
case 'Toggle Tags':
settings.updateSetting('showTags', !settings.showTags);
break;
@ -436,6 +459,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) FocusScope.of(context).unfocus();
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;
@ -444,6 +474,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
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(
@ -458,6 +495,20 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
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(
@ -508,18 +559,29 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
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: Colors.orange,
color: app.isDemoted ? Colors.grey : Colors.teal,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 20.0),
child: Icon(Icons.edit, color: Colors.white),
child: Icon(
app.isDemoted ? Icons.arrow_upward : Icons.push_pin,
color: Colors.white,
),
),
secondaryBackground: Container(
color: Colors.green,
color: app.isPinned ? Colors.grey : Colors.deepOrange,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20.0),
child: Icon(Icons.info, color: Colors.white),
child: Icon(
app.isPinned ? Icons.push_pin_outlined : Icons.arrow_downward,
color: Colors.white,
),
),
child: ListTile(
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
@ -554,12 +616,14 @@ Tap an app to launch it immediately.
## Tagging:
Swipe right on any item to edit its tags. For shared files, a tag dialog appears immediately upon sharing.
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 left on an app to view its 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.
---

View file

@ -8,6 +8,8 @@ class AppSettings extends ChangeNotifier {
bool _darkTheme = false;
bool _sortLastUsed = true;
bool _autoFocus = true;
bool _showStatusIcons = true;
bool _hideDemoted = false;
bool get showTags => _showTags;
bool get showIcons => _showIcons;
@ -15,6 +17,8 @@ class AppSettings extends ChangeNotifier {
bool get darkTheme => _darkTheme;
bool get sortLastUsed => _sortLastUsed;
bool get autoFocus => _autoFocus;
bool get showStatusIcons => _showStatusIcons;
bool get hideDemoted => _hideDemoted;
final SharedPreferencesService _prefsService = SharedPreferencesService();
@ -29,6 +33,8 @@ class AppSettings extends ChangeNotifier {
_darkTheme = _prefsService.getBool('darkTheme', false);
_sortLastUsed = _prefsService.getBool('sortLastUsed', true);
_autoFocus = _prefsService.getBool('autoFocus', true);
_showStatusIcons = _prefsService.getBool('showStatusIcons', true);
_hideDemoted = _prefsService.getBool('hideDemoted', false);
notifyListeners();
}
@ -52,6 +58,12 @@ class AppSettings extends ChangeNotifier {
case 'autoFocus':
_autoFocus = value;
break;
case 'showStatusIcons':
_showStatusIcons = value;
break;
case 'hideDemoted':
_hideDemoted = value;
break;
}
await _prefsService.setBool(key, value);
notifyListeners(); // Notify listeners after updating the setting