plasmoid/lib/main.dart

571 lines
19 KiB
Dart
Raw Normal View History

2026-06-19 12:51:27 +03:00
import 'dart:async';
2025-02-24 12:57:02 +02:00
import 'dart:typed_data';
2025-02-24 15:16:26 +02:00
import 'package:flutter/foundation.dart';
2025-02-24 12:57:02 +02:00
import 'package:flutter/material.dart';
import 'package:device_apps/device_apps.dart';
2025-02-24 15:16:26 +02:00
import 'package:flutter/services.dart';
2025-02-24 12:57:02 +02:00
import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_fonts/google_fonts.dart';
2025-02-24 21:46:40 +02:00
import 'package:plasmoid/preferences.dart';
2025-02-24 15:44:25 +02:00
import 'package:plasmoid/read_only.dart';
2025-02-24 21:46:40 +02:00
import 'package:plasmoid/settings.dart';
import 'package:provider/provider.dart';
2025-02-24 12:57:02 +02:00
import 'day.dart';
import 'night.dart';
2025-02-24 21:46:40 +02:00
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SharedPreferencesService().initialize();
runApp(
ChangeNotifierProvider(
create: (_) => AppSettings(),
child: MyApp(),
),
);
2025-02-24 12:57:02 +02:00
}
2025-02-24 21:46:40 +02:00
class MyApp extends StatelessWidget {
2026-06-19 12:37:17 +03:00
const MyApp({super.key});
2025-02-24 12:57:02 +02:00
@override
Widget build(BuildContext context) {
2025-02-24 21:46:40 +02:00
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(),
);
},
2025-02-24 12:57:02 +02:00
);
}
}
class AppInfo {
final Uint8List? icon;
final String name;
final String packageName;
final List<String> tags;
2025-02-24 22:04:57 +02:00
DateTime? lastUsed;
bool isPinned;
bool isDemoted;
2025-02-24 12:57:02 +02:00
AppInfo({
this.icon,
required this.name,
required this.packageName,
required this.tags,
2025-02-24 13:27:02 +02:00
this.lastUsed,
2025-02-24 22:04:57 +02:00
this.isPinned = false,
this.isDemoted = false,
2025-02-24 12:57:02 +02:00
});
}
class AppLauncherScreen extends StatefulWidget {
@override
_AppLauncherScreenState createState() => _AppLauncherScreenState();
}
2025-02-24 21:46:40 +02:00
class _AppLauncherScreenState extends State<AppLauncherScreen> with WidgetsBindingObserver {
2025-02-24 12:57:02 +02:00
final TextEditingController searchController = TextEditingController();
2025-02-24 14:04:31 +02:00
final FocusNode searchFocusNode = FocusNode();
2025-02-24 12:57:02 +02:00
List<AppInfo> apps = [];
List<AppInfo> filteredApps = [];
bool isLoading = true;
2026-06-19 12:51:27 +03:00
StreamSubscription<ApplicationEvent>? _appsChangeSub;
2025-02-24 12:57:02 +02:00
@override
void initState() {
super.initState();
2025-02-24 21:46:40 +02:00
WidgetsBinding.instance.addObserver(this);
2025-02-24 12:57:02 +02:00
_loadInstalledApps();
2026-06-19 12:51:27 +03:00
_appsChangeSub = DeviceApps.listenToAppsChanges().listen((_) => _loadInstalledApps());
2025-02-24 12:57:02 +02:00
searchController.addListener(_filterApps);
2025-02-24 14:04:31 +02:00
WidgetsBinding.instance.addPostFrameCallback((_) {
2025-02-24 21:46:40 +02:00
if (context.read<AppSettings>().autoFocus) {
2025-02-24 14:04:31 +02:00
FocusScope.of(context).requestFocus(searchFocusNode);
}
});
2025-02-24 12:57:02 +02:00
}
2025-02-24 21:46:40 +02:00
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
2026-06-19 12:51:27 +03:00
_appsChangeSub?.cancel();
2025-02-24 21:46:40 +02:00
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_loadInstalledApps();
}
}
2025-02-24 12:57:02 +02:00
Future<void> _loadInstalledApps() async {
List<Application> installedApps = await DeviceApps.getInstalledApplications(
includeAppIcons: true,
onlyAppsWithLaunchIntent: true,
2025-02-24 21:46:40 +02:00
includeSystemApps: true,
2025-02-24 12:57:02 +02:00
);
List<AppInfo> loadedApps = installedApps.map((app) {
2025-02-24 21:46:40 +02:00
final lastUsedMillis = SharedPreferencesService().getInt("lastUsed_${app.packageName}");
final storedTags = SharedPreferencesService().getString("tags_${app.packageName}");
2025-02-24 22:04:57 +02:00
final isPinned = SharedPreferencesService().getBool("isPinned_${app.packageName}", false);
final isDemoted = SharedPreferencesService().getBool("isDemoted_${app.packageName}", false);
2025-02-24 14:04:31 +02:00
List<String> tags = [];
if (storedTags != null && storedTags.trim().isNotEmpty) {
tags = storedTags.split(" ");
2025-02-24 21:46:40 +02:00
} else if (app.category != null) {
String categoryStr = app.category.toString().split('.').last;
if (categoryStr.toLowerCase() != 'undefined') {
tags.add('@${categoryStr.toLowerCase()}');
2025-02-24 14:04:31 +02:00
}
}
2025-02-24 12:57:02 +02:00
return AppInfo(
icon: app is ApplicationWithIcon ? app.icon : null,
name: app.appName,
packageName: app.packageName,
2025-02-24 14:04:31 +02:00
tags: tags,
2025-02-24 21:46:40 +02:00
lastUsed: lastUsedMillis != null ? DateTime.fromMillisecondsSinceEpoch(lastUsedMillis) : null,
2025-02-24 22:04:57 +02:00
isPinned: isPinned,
isDemoted: isDemoted,
2025-02-24 12:57:02 +02:00
);
}).toList();
setState(() {
apps = loadedApps;
2025-02-24 14:04:31 +02:00
filteredApps = apps;
2025-02-24 12:57:02 +02:00
isLoading = false;
});
2025-02-24 13:27:02 +02:00
_filterApps();
2025-02-24 12:57:02 +02:00
}
void _filterApps() {
String query = searchController.text.toLowerCase();
setState(() {
filteredApps = apps.where((app) {
bool matchesName = app.name.toLowerCase().contains(query);
bool matchesTag = app.tags.any((tag) => tag.toLowerCase().contains(query));
return matchesName || matchesTag;
}).toList();
2025-02-24 22:04:57 +02:00
// Sort pinned apps first, then by last used or alphabetically, then demoted apps
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) {
2025-02-24 21:46:40 +02:00
DateTime aTime = a.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
DateTime bTime = b.lastUsed ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
2025-02-24 22:04:57 +02:00
} else {
return a.name.compareTo(b.name);
}
});
2025-02-24 12:57:02 +02:00
});
}
Future<void> _launchApp(AppInfo app) async {
bool launched = await DeviceApps.openApp(app.packageName);
2025-02-24 13:27:02 +02:00
if (launched) {
DateTime now = DateTime.now();
2025-02-24 21:46:40 +02:00
await SharedPreferencesService().setInt("lastUsed_${app.packageName}", now.millisecondsSinceEpoch);
2025-02-24 13:27:02 +02:00
setState(() {
app.lastUsed = now;
_filterApps();
});
} else {
2025-02-24 12:57:02 +02:00
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name}')),
);
}
}
Future<bool> _handleDismiss(DismissDirection direction, AppInfo app) async {
if (direction == DismissDirection.endToStart) {
_openAppInfo(app);
} else if (direction == DismissDirection.startToEnd) {
_editAppTags(app);
}
return false;
}
Future<void> _openAppInfo(AppInfo app) async {
bool launched = await DeviceApps.openAppSettings(app.packageName);
if (!launched) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not launch ${app.name} Settings')),
);
}
}
2025-02-24 22:04:57 +02:00
void _showContextMenu(BuildContext context, AppInfo app) {
showModalBottomSheet(
context: context,
builder: (context) {
return Column(
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);
},
),
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);
},
),
],
);
},
);
}
Future<void> _togglePin(AppInfo app) async {
setState(() {
app.isPinned = !app.isPinned;
if (app.isPinned) {
app.isDemoted = false; // Cannot be pinned and demoted at the same time
}
});
await SharedPreferencesService().setBool("isPinned_${app.packageName}", app.isPinned);
_filterApps();
}
Future<void> _toggleDemote(AppInfo app) async {
setState(() {
app.isDemoted = !app.isDemoted;
if (app.isDemoted) {
app.isPinned = false; // Cannot be pinned and demoted at the same time
}
});
await SharedPreferencesService().setBool("isDemoted_${app.packageName}", app.isDemoted);
_filterApps();
}
2025-02-24 12:57:02 +02:00
void _editAppTags(AppInfo app) {
2025-02-24 21:46:40 +02:00
TextEditingController tagController = TextEditingController(text: app.tags.join(" "));
2025-02-24 12:57:02 +02:00
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(
2025-02-24 14:04:31 +02:00
onPressed: () async {
2025-02-24 12:57:02 +02:00
setState(() {
app.tags
..clear()
2025-02-24 14:04:31 +02:00
..addAll(tagController.text.split(" ").where((t) => t.isNotEmpty));
2025-02-24 12:57:02 +02:00
});
2025-02-24 21:46:40 +02:00
await SharedPreferencesService().setString("tags_${app.packageName}", app.tags.join(" "));
2025-02-24 12:57:02 +02:00
Navigator.of(context).pop();
_filterApps();
},
child: Text('Save'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
],
),
);
}
2025-02-24 14:04:31 +02:00
List<InlineSpan> _formatTags(String content) {
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
2025-02-24 21:46:40 +02:00
final Set<String> tagSet = {};
2025-02-24 14:04:31 +02:00
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;
2025-02-24 21:46:40 +02:00
return a.compareTo(b);
2025-02-24 14:04:31 +02:00
});
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();
}
2025-02-24 12:57:02 +02:00
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);
}
2025-02-24 21:46:40 +02:00
if (!context.read<AppSettings>().colorIcons) {
2025-02-24 12:57:02 +02:00
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;
}
2025-02-24 21:46:40 +02:00
void _showInfoPage(BuildContext context, String title, String content) {
2025-02-24 15:44:25 +02:00
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ReadOnlyPage(
title: title,
content: content,
2025-02-24 21:46:40 +02:00
isDarkMode: context.read<AppSettings>().darkTheme,
2025-02-24 15:44:25 +02:00
),
),
);
}
2025-02-24 22:04:57 +02:00
Widget _buildTrailingIcon(AppInfo app) {
if (app.isPinned) {
return Icon(Icons.push_pin, color: Colors.grey); // Pin icon for pinned apps
} else if (app.isDemoted) {
return Icon(Icons.disabled_by_default_outlined, color: Colors.grey); // Demote icon for demoted apps
}
return SizedBox.shrink(); // No icon for normal apps
}
2025-02-24 12:57:02 +02:00
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
2025-02-24 21:46:40 +02:00
focusNode: context.read<AppSettings>().autoFocus ? searchFocusNode : null,
2025-02-24 12:57:02 +02:00
controller: searchController,
decoration: InputDecoration(
hintText: 'Search apps...',
border: InputBorder.none,
),
style: TextStyle(color: Colors.white),
),
actions: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
2025-02-24 14:04:31 +02:00
child: GestureDetector(
onTap: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
2025-02-24 12:57:02 +02:00
}
},
2025-02-24 14:04:31 +02:00
child: PopupMenuButton<String>(
enabled: searchController.text.isEmpty,
icon: SvgPicture.asset(
"assets/plasmoid.svg",
width: 30,
2026-06-19 12:37:17 +03:00
colorFilter: const ColorFilter.mode(Color(0xff38818a), BlendMode.srcIn),
2025-02-24 12:57:02 +02:00
),
2025-02-24 21:46:40 +02:00
onSelected: (String value) async {
final settings = context.read<AppSettings>();
2025-02-24 14:04:31 +02:00
switch (value) {
case 'Toggle Tags':
2025-02-24 21:46:40 +02:00
settings.updateSetting('showTags', !settings.showTags);
2025-02-24 14:04:31 +02:00
break;
case 'Toggle Icons':
2025-02-24 21:46:40 +02:00
settings.updateSetting('showIcons', !settings.showIcons);
2025-02-24 14:04:31 +02:00
break;
case 'Toggle Color Icons':
2025-02-24 21:46:40 +02:00
settings.updateSetting('colorIcons', !settings.colorIcons);
2025-02-24 14:04:31 +02:00
break;
case 'Toggle Theme':
2025-02-24 21:46:40 +02:00
settings.updateSetting('darkTheme', !settings.darkTheme);
2025-02-24 14:04:31 +02:00
break;
case 'Toggle Sorting':
2025-02-24 21:46:40 +02:00
settings.updateSetting('sortLastUsed', !settings.sortLastUsed);
2025-02-24 14:04:31 +02:00
_filterApps();
break;
case 'Toggle AutoFocus':
2025-02-24 21:46:40 +02:00
settings.updateSetting('autoFocus', !settings.autoFocus);
if (!settings.autoFocus) FocusScope.of(context).unfocus();
2025-02-24 14:04:31 +02:00
break;
2025-02-24 15:44:25 +02:00
case 'About':
_showInfoPage(context, "About", aboutContent);
break;
2025-02-24 14:04:31 +02:00
}
},
2025-02-24 21:46:40 +02:00
itemBuilder: (BuildContext context) {
final settings = context.read<AppSettings>();
return [
PopupMenuItem<String>(
value: 'Toggle Tags',
child: ListTile(
leading: Icon(settings.showTags ? Icons.short_text : Icons.tag),
title: Text(settings.showTags ? 'Hide Tags' : 'Show Tags'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
PopupMenuItem<String>(
value: 'Toggle Icons',
child: ListTile(
leading: Icon(settings.showIcons ? Icons.apps : Icons.apps_outlined),
title: Text(settings.showIcons ? 'Hide Icons' : 'Show Icons'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
PopupMenuItem<String>(
value: 'Toggle Color Icons',
child: ListTile(
leading: Icon(Icons.palette),
title: Text(settings.colorIcons ? 'Use Monochrome Icons' : 'Use Color Icons'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
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'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
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'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
PopupMenuItem<String>(
value: 'Toggle AutoFocus',
child: ListTile(
leading: Icon(Icons.center_focus_strong),
title: Text(settings.autoFocus ? 'Disable Auto Focus' : 'Enable Auto Focus'),
),
2025-02-24 14:04:31 +02:00
),
2025-02-24 21:46:40 +02:00
PopupMenuItem<String>(
value: 'About',
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
),
2025-02-24 15:44:25 +02:00
),
2025-02-24 21:46:40 +02:00
];
},
2025-02-24 14:04:31 +02:00
),
2025-02-24 12:57:02 +02:00
),
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: filteredApps.length,
itemBuilder: (context, index) {
final app = filteredApps[index];
return Dismissible(
key: Key(app.packageName),
confirmDismiss: (direction) => _handleDismiss(direction, app),
background: Container(
color: Colors.orange,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 20.0),
child: Icon(Icons.edit, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.green,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20.0),
child: Icon(Icons.info, color: Colors.white),
),
child: ListTile(
2025-02-24 21:46:40 +02:00
leading: context.read<AppSettings>().showIcons ? _buildAppIcon(app) : null,
2025-02-24 12:57:02 +02:00
title: Text(app.name),
2025-02-24 21:46:40 +02:00
subtitle: context.read<AppSettings>().showTags
2025-02-24 14:04:31 +02:00
? RichText(
2025-02-24 21:46:40 +02:00
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: _formatTags(app.tags.join(' ')),
),
overflow: TextOverflow.ellipsis,
)
2025-02-24 14:04:31 +02:00
: null,
2025-02-24 22:04:57 +02:00
trailing: _buildTrailingIcon(app), // Add trailing icon
2025-02-24 12:57:02 +02:00
onTap: () => _launchApp(app),
2025-02-24 22:04:57 +02:00
onLongPress: () => _showContextMenu(context, app),
2025-02-24 12:57:02 +02:00
),
);
},
2025-02-24 22:04:57 +02:00
)
2025-02-24 12:57:02 +02:00
);
}
}
2025-02-24 15:44:25 +02:00
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:
Swipe right on any item to edit its tags. For shared files, a tag dialog appears immediately upon sharing.
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.
Use the menu button (plasmoid) to toggle settings such as icon visibility, color mode, theme, sorting (alphabetically or by last used), and auto-focus.
---
2025-02-24 16:53:45 +02:00
Made with 💚 by randogoth
2025-02-24 15:44:25 +02:00
Icon by [Game Icons.net](https://game-icons.net/).
""";