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

@ -6,7 +6,7 @@ plugins {
} }
android { android {
namespace = "vision.flux.plasmoid" namespace = "com.randogoth.plasmoid"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileSdkVersion 36 compileSdkVersion 36
@ -22,7 +22,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "vision.flux.plasmoid" applicationId = "com.randogoth.plasmoid"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View file

@ -1,4 +1,4 @@
package vision.flux.plasmoid package com.randogoth.plasmoid
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:device_apps/device_apps.dart'; import 'package:device_apps/device_apps.dart';
@ -74,8 +73,10 @@ class AppInfo {
} }
class AppLauncherScreen extends StatefulWidget { class AppLauncherScreen extends StatefulWidget {
const AppLauncherScreen({super.key});
@override @override
_AppLauncherScreenState createState() => _AppLauncherScreenState(); State<AppLauncherScreen> createState() => _AppLauncherScreenState();
} }
class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver { class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver {
@ -84,6 +85,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
List<AppInfo> apps = []; List<AppInfo> apps = [];
List<AppInfo> filteredApps = []; List<AppInfo> filteredApps = [];
bool isLoading = true; bool isLoading = true;
bool _loadingApps = false;
StreamSubscription<ApplicationEvent>? _appsChangeSub; StreamSubscription<ApplicationEvent>? _appsChangeSub;
@override @override
@ -115,84 +117,93 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
Future<void> _loadInstalledApps() async { Future<void> _loadInstalledApps() async {
List<Application> installedApps = await DeviceApps.getInstalledApplications( if (_loadingApps) return;
includeAppIcons: true, _loadingApps = true;
onlyAppsWithLaunchIntent: true, try {
includeSystemApps: true, List<Application> installedApps = await DeviceApps.getInstalledApplications(
); includeAppIcons: true,
onlyAppsWithLaunchIntent: true,
List<AppInfo> loadedApps = installedApps.map((app) { includeSystemApps: true,
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,
); );
}).toList();
setState(() { if (!mounted) return;
apps = loadedApps;
filteredApps = apps; List<AppInfo> loadedApps = installedApps.map((app) {
isLoading = false; final lastUsedMillis = SharedPreferencesService().getInt("lastUsed_${app.packageName}");
}); final storedTags = SharedPreferencesService().getString("tags_${app.packageName}");
_filterApps(); 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() { void _filterApps() {
String query = searchController.text.toLowerCase(); String query = searchController.text.toLowerCase();
setState(() { setState(() {
final hideDemoted = context.read<AppSettings>().hideDemoted;
filteredApps = apps.where((app) { filteredApps = apps.where((app) {
if (hideDemoted && app.isDemoted) return false;
bool matchesName = app.name.toLowerCase().contains(query); bool matchesName = app.name.toLowerCase().contains(query);
bool matchesTag = app.tags.any((tag) => tag.toLowerCase().contains(query)); bool matchesTag = app.tags.any((tag) => tag.toLowerCase().contains(query));
return matchesName || matchesTag; return matchesName || matchesTag;
}).toList(); }).toList();
// Sort pinned apps first, then by last used or alphabetically, then demoted apps // Sort pinned apps first, then by last used or alphabetically, then demoted apps
final sortLastUsed = context.read<AppSettings>().sortLastUsed;
filteredApps.sort((a, b) { filteredApps.sort((a, b) {
if (a.isPinned && !b.isPinned) return -1; if (a.isPinned && !b.isPinned) return -1;
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 (!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 aTime = a.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
DateTime bTime = b.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0); DateTime bTime = b.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime); final timeCompare = bTime.compareTo(aTime);
} else { if (timeCompare != 0) return timeCompare;
return a.name.compareTo(b.name);
} }
return a.name.compareTo(b.name);
}); });
}); });
} }
Future<void> _launchApp(AppInfo app) async { Future<void> _launchApp(AppInfo app) async {
bool launched = await DeviceApps.openApp(app.packageName); bool launched = await DeviceApps.openApp(app.packageName);
if (!mounted) return;
if (launched) { if (launched) {
DateTime now = DateTime.now(); DateTime now = DateTime.now();
await SharedPreferencesService().setInt("lastUsed_${app.packageName}", now.millisecondsSinceEpoch); await SharedPreferencesService().setInt("lastUsed_${app.packageName}", now.millisecondsSinceEpoch);
setState(() { setState(() { app.lastUsed = now; });
app.lastUsed = now; _filterApps();
_filterApps();
});
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name}')), 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 { Future<bool> _handleDismiss(DismissDirection direction, AppInfo app) async {
if (direction == DismissDirection.endToStart) { if (direction == DismissDirection.startToEnd) {
_openAppInfo(app); await (app.isDemoted ? _toggleDemote(app) : _togglePin(app));
} else if (direction == DismissDirection.startToEnd) { } else if (direction == DismissDirection.endToStart) {
_editAppTags(app); await (app.isPinned ? _togglePin(app) : _toggleDemote(app));
} }
return false; return false;
} }
Future<void> _openAppInfo(AppInfo app) async { Future<void> _openAppInfo(AppInfo app) async {
bool launched = await DeviceApps.openAppSettings(app.packageName); bool launched = await DeviceApps.openAppSettings(app.packageName);
if (!mounted) return;
if (!launched) { if (!launched) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name} Settings')), SnackBar(content: Text('Could not launch ${app.name} Settings')),
@ -226,19 +238,19 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: Icon(app.isPinned ? Icons.push_pin : Icons.push_pin_outlined), leading: Icon(Icons.edit),
title: Text(app.isPinned ? 'Unpin' : 'Pin'), title: Text('Edit Tags'),
onTap: () async { onTap: () {
Navigator.pop(context); // Close the menu Navigator.pop(context);
await _togglePin(app); _editAppTags(app);
}, },
), ),
ListTile( ListTile(
leading: Icon(app.isDemoted ? Icons.arrow_upward : Icons.arrow_downward), leading: Icon(Icons.info_outline),
title: Text(app.isDemoted ? 'Reset' : 'Demote'), title: Text('App Info'),
onTap: () async { onTap: () {
Navigator.pop(context); // Close the menu Navigator.pop(context);
await _toggleDemote(app); _openAppInfo(app);
}, },
), ),
], ],
@ -255,6 +267,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
}); });
await SharedPreferencesService().setBool("isPinned_${app.packageName}", app.isPinned); await SharedPreferencesService().setBool("isPinned_${app.packageName}", app.isPinned);
if (app.isPinned) { // pinning clears demote
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", false);
}
_filterApps(); _filterApps();
} }
@ -266,6 +281,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
}); });
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", app.isDemoted); await SharedPreferencesService().setBool("isDemoted_${app.packageName}", app.isDemoted);
if (app.isDemoted) { // demoting clears pin
await SharedPreferencesService().setBool("isPinned_${app.packageName}", false);
}
_filterApps(); _filterApps();
} }
@ -290,6 +308,7 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
..addAll(tagController.text.split(" ").where((t) => t.isNotEmpty)); ..addAll(tagController.text.split(" ").where((t) => t.isNotEmpty));
}); });
await SharedPreferencesService().setString("tags_${app.packageName}", app.tags.join(" ")); await SharedPreferencesService().setString("tags_${app.packageName}", app.tags.join(" "));
if (!mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
_filterApps(); _filterApps();
}, },
@ -376,12 +395,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
} }
Widget _buildTrailingIcon(AppInfo app) { Widget _buildTrailingIcon(AppInfo app) {
if (!context.read<AppSettings>().showStatusIcons) return SizedBox.shrink();
if (app.isPinned) { 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) { } 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 @override
@ -416,6 +436,9 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
onSelected: (String value) async { onSelected: (String value) async {
final settings = context.read<AppSettings>(); final settings = context.read<AppSettings>();
switch (value) { switch (value) {
case 'Refresh':
_loadInstalledApps();
break;
case 'Toggle Tags': case 'Toggle Tags':
settings.updateSetting('showTags', !settings.showTags); settings.updateSetting('showTags', !settings.showTags);
break; break;
@ -436,6 +459,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
settings.updateSetting('autoFocus', !settings.autoFocus); settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) FocusScope.of(context).unfocus(); if (!settings.autoFocus) FocusScope.of(context).unfocus();
break; break;
case 'Toggle StatusIcons':
settings.updateSetting('showStatusIcons', !settings.showStatusIcons);
break;
case 'Toggle HideDemoted':
settings.updateSetting('hideDemoted', !settings.hideDemoted);
_filterApps();
break;
case 'About': case 'About':
_showInfoPage(context, "About", aboutContent); _showInfoPage(context, "About", aboutContent);
break; break;
@ -444,6 +474,13 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
itemBuilder: (BuildContext context) { itemBuilder: (BuildContext context) {
final settings = context.read<AppSettings>(); final settings = context.read<AppSettings>();
return [ return [
PopupMenuItem<String>(
value: 'Refresh',
child: ListTile(
leading: Icon(Icons.refresh),
title: Text('Refresh App List'),
),
),
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'Toggle Tags', value: 'Toggle Tags',
child: ListTile( child: ListTile(
@ -458,6 +495,20 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
title: Text(settings.showIcons ? 'Hide Icons' : 'Show Icons'), 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>( PopupMenuItem<String>(
value: 'Toggle Color Icons', value: 'Toggle Color Icons',
child: ListTile( child: ListTile(
@ -508,18 +559,29 @@ class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindi
final app = filteredApps[index]; final app = filteredApps[index];
return Dismissible( return Dismissible(
key: Key(app.packageName), key: Key(app.packageName),
direction: app.isPinned
? DismissDirection.endToStart
: app.isDemoted
? DismissDirection.startToEnd
: DismissDirection.horizontal,
confirmDismiss: (direction) => _handleDismiss(direction, app), confirmDismiss: (direction) => _handleDismiss(direction, app),
background: Container( background: Container(
color: Colors.orange, color: app.isDemoted ? Colors.grey : Colors.teal,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 20.0), 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( secondaryBackground: Container(
color: Colors.green, color: app.isPinned ? Colors.grey : Colors.deepOrange,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20.0), 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( child: ListTile(
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null, leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
@ -554,12 +616,14 @@ Tap an app to launch it immediately.
## Tagging: ## 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. Tags help you organize items and are searchable using prefixes like @context, #general, or +project.
## Gestures & Settings: ## 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. 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 _darkTheme = false;
bool _sortLastUsed = true; bool _sortLastUsed = true;
bool _autoFocus = true; bool _autoFocus = true;
bool _showStatusIcons = true;
bool _hideDemoted = false;
bool get showTags => _showTags; bool get showTags => _showTags;
bool get showIcons => _showIcons; bool get showIcons => _showIcons;
@ -15,6 +17,8 @@ class AppSettings extends ChangeNotifier {
bool get darkTheme => _darkTheme; bool get darkTheme => _darkTheme;
bool get sortLastUsed => _sortLastUsed; bool get sortLastUsed => _sortLastUsed;
bool get autoFocus => _autoFocus; bool get autoFocus => _autoFocus;
bool get showStatusIcons => _showStatusIcons;
bool get hideDemoted => _hideDemoted;
final SharedPreferencesService _prefsService = SharedPreferencesService(); final SharedPreferencesService _prefsService = SharedPreferencesService();
@ -29,6 +33,8 @@ class AppSettings extends ChangeNotifier {
_darkTheme = _prefsService.getBool('darkTheme', false); _darkTheme = _prefsService.getBool('darkTheme', false);
_sortLastUsed = _prefsService.getBool('sortLastUsed', true); _sortLastUsed = _prefsService.getBool('sortLastUsed', true);
_autoFocus = _prefsService.getBool('autoFocus', true); _autoFocus = _prefsService.getBool('autoFocus', true);
_showStatusIcons = _prefsService.getBool('showStatusIcons', true);
_hideDemoted = _prefsService.getBool('hideDemoted', false);
notifyListeners(); notifyListeners();
} }
@ -52,6 +58,12 @@ class AppSettings extends ChangeNotifier {
case 'autoFocus': case 'autoFocus':
_autoFocus = value; _autoFocus = value;
break; break;
case 'showStatusIcons':
_showStatusIcons = value;
break;
case 'hideDemoted':
_hideDemoted = value;
break;
} }
await _prefsService.setBool(key, value); await _prefsService.setBool(key, value);
notifyListeners(); // Notify listeners after updating the setting notifyListeners(); // Notify listeners after updating the setting