613 lines
21 KiB
Dart
613 lines
21 KiB
Dart
import 'dart:typed_data';
|
|
import 'package:flutter/foundation.dart';
|
|
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/read_only.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'day.dart';
|
|
import 'night.dart';
|
|
|
|
void main() {
|
|
LicenseRegistry.addLicense(() async* {
|
|
final license = await rootBundle.loadString('google_fonts/OFL.txt');
|
|
yield LicenseEntryWithLineBreaks(['google_fonts'], license);
|
|
});
|
|
runApp(MyApp());
|
|
}
|
|
|
|
class AppSettings {
|
|
bool showTags;
|
|
bool showIcons;
|
|
bool colorIcons; // true = color icons; false = monochrome icons
|
|
bool darkTheme;
|
|
bool sortAlphabetically;
|
|
bool autoFocus; // new
|
|
|
|
AppSettings({
|
|
this.showTags = true,
|
|
this.showIcons = true,
|
|
this.colorIcons = true,
|
|
this.darkTheme = false,
|
|
this.sortAlphabetically = false,
|
|
this.autoFocus = true,
|
|
});
|
|
}
|
|
|
|
class MyApp extends StatefulWidget {
|
|
@override
|
|
_MyAppState createState() => _MyAppState();
|
|
}
|
|
|
|
class _MyAppState extends State<MyApp> {
|
|
AppSettings settings = AppSettings();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSettings();
|
|
}
|
|
|
|
Future<void> _loadSettings() async {
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
setState(() {
|
|
settings.showTags = prefs.getBool('showTags') ?? true;
|
|
settings.showIcons = prefs.getBool('showIcons') ?? true;
|
|
settings.colorIcons = prefs.getBool('colorIcons') ?? true;
|
|
settings.darkTheme = prefs.getBool('darkTheme') ?? false;
|
|
settings.sortAlphabetically = prefs.getBool('sortAlphabetically') ?? false;
|
|
settings.autoFocus = prefs.getBool('autoFocus') ?? true;
|
|
});
|
|
}
|
|
|
|
Future<void> _updateSetting(String key, bool value) async {
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(key, value);
|
|
}
|
|
|
|
void _updateSettings(AppSettings newSettings) {
|
|
setState(() {
|
|
settings = newSettings;
|
|
});
|
|
_updateSetting('showTags', settings.showTags);
|
|
_updateSetting('showIcons', settings.showIcons);
|
|
_updateSetting('colorIcons', settings.colorIcons);
|
|
_updateSetting('darkTheme', settings.darkTheme);
|
|
_updateSetting('sortAlphabetically', settings.sortAlphabetically);
|
|
_updateSetting('autoFocus', settings.autoFocus);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(
|
|
settings: settings,
|
|
onSettingsChanged: _updateSettings,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AppInfo {
|
|
final Uint8List? icon;
|
|
final String name;
|
|
final String packageName;
|
|
final List<String> tags;
|
|
DateTime? lastUsed; // New field to track last used time
|
|
|
|
AppInfo({
|
|
this.icon,
|
|
required this.name,
|
|
required this.packageName,
|
|
required this.tags,
|
|
this.lastUsed,
|
|
});
|
|
}
|
|
|
|
class AppLauncherScreen extends StatefulWidget {
|
|
final AppSettings settings;
|
|
final Function(AppSettings) onSettingsChanged;
|
|
|
|
AppLauncherScreen({required this.settings, required this.onSettingsChanged});
|
|
|
|
@override
|
|
_AppLauncherScreenState createState() => _AppLauncherScreenState();
|
|
}
|
|
|
|
class _AppLauncherScreenState extends State<AppLauncherScreen> {
|
|
final TextEditingController searchController = TextEditingController();
|
|
final FocusNode searchFocusNode = FocusNode();
|
|
List<AppInfo> apps = [];
|
|
List<AppInfo> filteredApps = [];
|
|
bool isLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadInstalledApps();
|
|
searchController.addListener(_filterApps);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (widget.settings.autoFocus) {
|
|
FocusScope.of(context).requestFocus(searchFocusNode);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadInstalledApps() async {
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
List<Application> installedApps = await DeviceApps.getInstalledApplications(
|
|
includeAppIcons: true,
|
|
onlyAppsWithLaunchIntent: true,
|
|
includeSystemApps: true
|
|
);
|
|
|
|
List<AppInfo> loadedApps = installedApps.map((app) {
|
|
final lastUsedMillis = prefs.getInt("lastUsed_${app.packageName}");
|
|
final storedTags = prefs.getString("tags_${app.packageName}");
|
|
List<String> tags = [];
|
|
|
|
if (storedTags != null && storedTags.trim().isNotEmpty) {
|
|
// Use the stored user-edited tags.
|
|
tags = storedTags.split(" ");
|
|
} else {
|
|
// Fall back to using the auto-generated category tag.
|
|
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,
|
|
);
|
|
}).toList();
|
|
|
|
setState(() {
|
|
apps = loadedApps;
|
|
filteredApps = apps;
|
|
isLoading = false;
|
|
});
|
|
_filterApps();
|
|
}
|
|
|
|
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();
|
|
|
|
if (widget.settings.sortAlphabetically) {
|
|
filteredApps.sort((a, b) => a.name.compareTo(b.name));
|
|
print('Sorting alphabetically');
|
|
} else {
|
|
filteredApps.sort((a, b) {
|
|
// If both have a lastUsed timestamp, sort descending.
|
|
if (a.lastUsed != null && b.lastUsed != null) {
|
|
int cmp = b.lastUsed!.compareTo(a.lastUsed!);
|
|
if (cmp != 0) return cmp;
|
|
return a.name.compareTo(b.name);
|
|
}
|
|
// If one has a timestamp, that one comes first.
|
|
if (a.lastUsed != null) return -1;
|
|
if (b.lastUsed != null) return 1;
|
|
// Both null: fallback alphabetical.
|
|
return a.name.compareTo(b.name);
|
|
});
|
|
print('Sorting by last used, with fallback alphabetical');
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _launchApp(AppInfo app) async {
|
|
bool launched = await DeviceApps.openApp(app.packageName);
|
|
if (launched) {
|
|
DateTime now = DateTime.now();
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.setInt("lastUsed_${app.packageName}", now.millisecondsSinceEpoch);
|
|
// Update the lastUsed timestamp and re-sort immediately.
|
|
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.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')),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: () async {
|
|
setState(() {
|
|
app.tags
|
|
..clear()
|
|
..addAll(tagController.text.split(" ").where((t) => t.isNotEmpty));
|
|
});
|
|
// Persist the updated tags.
|
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString("tags_${app.packageName}", app.tags.join(" "));
|
|
Navigator.of(context).pop();
|
|
_filterApps();
|
|
},
|
|
child: Text('Save'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<InlineSpan> _formatTags(String content) {
|
|
final tagPattern = RegExp(r'([#@+][a-zA-Z0-9_]+)');
|
|
final Set<String> tagSet = {}; // Avoid duplicate tags
|
|
|
|
for (final match in tagPattern.allMatches(content)) {
|
|
tagSet.add(match.group(0)!);
|
|
}
|
|
|
|
// Sort tags: +tags before @tags before #tags
|
|
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); // Default alphabetical order
|
|
});
|
|
|
|
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 (!widget.settings.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;
|
|
}
|
|
|
|
_showInfoPage(BuildContext context, String title, String content) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ReadOnlyPage(
|
|
title: title,
|
|
content: content,
|
|
isDarkMode: widget.settings.darkTheme,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: TextField(
|
|
focusNode: widget.settings.autoFocus ? searchFocusNode : null,
|
|
controller: searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search apps...',
|
|
border: InputBorder.none,
|
|
),
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 15),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if (searchController.text.isNotEmpty) {
|
|
searchController.clear();
|
|
}
|
|
},
|
|
child: PopupMenuButton<String>(
|
|
enabled: searchController.text.isEmpty,
|
|
icon: SvgPicture.asset(
|
|
"assets/plasmoid.svg",
|
|
width: 30,
|
|
color: Color(0xff38818a),
|
|
),
|
|
onSelected: (String value) {
|
|
switch (value) {
|
|
case 'Toggle Tags':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: !widget.settings.showTags,
|
|
showIcons: widget.settings.showIcons,
|
|
colorIcons: widget.settings.colorIcons,
|
|
darkTheme: widget.settings.darkTheme,
|
|
sortAlphabetically: widget.settings.sortAlphabetically,
|
|
autoFocus: widget.settings.autoFocus,
|
|
));
|
|
break;
|
|
case 'Toggle Icons':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: widget.settings.showTags,
|
|
showIcons: !widget.settings.showIcons,
|
|
colorIcons: widget.settings.colorIcons,
|
|
darkTheme: widget.settings.darkTheme,
|
|
sortAlphabetically: widget.settings.sortAlphabetically,
|
|
autoFocus: widget.settings.autoFocus,
|
|
));
|
|
break;
|
|
case 'Toggle Color Icons':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: widget.settings.showTags,
|
|
showIcons: widget.settings.showIcons,
|
|
colorIcons: !widget.settings.colorIcons,
|
|
darkTheme: widget.settings.darkTheme,
|
|
sortAlphabetically: widget.settings.sortAlphabetically,
|
|
autoFocus: widget.settings.autoFocus,
|
|
));
|
|
break;
|
|
case 'Toggle Theme':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: widget.settings.showTags,
|
|
showIcons: widget.settings.showIcons,
|
|
colorIcons: widget.settings.colorIcons,
|
|
darkTheme: !widget.settings.darkTheme,
|
|
sortAlphabetically: widget.settings.sortAlphabetically,
|
|
autoFocus: widget.settings.autoFocus,
|
|
));
|
|
break;
|
|
case 'Toggle Sorting':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: widget.settings.showTags,
|
|
showIcons: widget.settings.showIcons,
|
|
colorIcons: widget.settings.colorIcons,
|
|
darkTheme: widget.settings.darkTheme,
|
|
sortAlphabetically: !widget.settings.sortAlphabetically,
|
|
autoFocus: widget.settings.autoFocus,
|
|
));
|
|
_filterApps();
|
|
break;
|
|
case 'Toggle AutoFocus':
|
|
widget.onSettingsChanged(AppSettings(
|
|
showTags: widget.settings.showTags,
|
|
showIcons: widget.settings.showIcons,
|
|
colorIcons: widget.settings.colorIcons,
|
|
darkTheme: widget.settings.darkTheme,
|
|
sortAlphabetically: widget.settings.sortAlphabetically,
|
|
autoFocus: !widget.settings.autoFocus,
|
|
));
|
|
if (!widget.settings.autoFocus) FocusScope.of(context).unfocus();
|
|
break;
|
|
case 'About':
|
|
_showInfoPage(context, "About", aboutContent);
|
|
break;
|
|
}
|
|
},
|
|
itemBuilder: (BuildContext context) => [
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Tags',
|
|
child: ListTile(
|
|
leading: Icon(widget.settings.showTags
|
|
? Icons.short_text
|
|
: Icons.tag),
|
|
title: Text(widget.settings.showTags
|
|
? 'Hide Tags'
|
|
: 'Show Tags'),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Icons',
|
|
child: ListTile(
|
|
leading: Icon(widget.settings.showIcons
|
|
? Icons.apps
|
|
: Icons.apps_outlined),
|
|
title: Text(widget.settings.showIcons
|
|
? 'Hide Icons'
|
|
: 'Show Icons'),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Color Icons',
|
|
child: ListTile(
|
|
leading: Icon(Icons.palette),
|
|
title: Text(widget.settings.colorIcons
|
|
? 'Use Monochrome Icons'
|
|
: 'Use Color Icons'),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Theme',
|
|
child: ListTile(
|
|
leading: Icon(widget.settings.darkTheme
|
|
? Icons.light_mode
|
|
: Icons.dark_mode),
|
|
title: Text(widget.settings.darkTheme
|
|
? 'Switch to Light Mode'
|
|
: 'Switch to Dark Mode'),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle Sorting',
|
|
child: ListTile(
|
|
leading: Icon(widget.settings.sortAlphabetically
|
|
? Icons.access_time
|
|
: Icons.sort_by_alpha),
|
|
title: Text(widget.settings.sortAlphabetically
|
|
? 'Sort Alphabetically'
|
|
: 'Sort by Last Used'),
|
|
),
|
|
),
|
|
PopupMenuItem<String>(
|
|
value: 'Toggle AutoFocus',
|
|
child: ListTile(
|
|
leading: Icon(Icons.center_focus_strong),
|
|
title: Text(widget.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(
|
|
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(
|
|
leading:
|
|
widget.settings.showIcons ? _buildAppIcon(app) : null,
|
|
title: Text(app.name),
|
|
subtitle:
|
|
widget.settings.showTags
|
|
? RichText(
|
|
text: TextSpan(
|
|
style: DefaultTextStyle.of(context).style,
|
|
children: _formatTags(app.tags.join(' ')), // ✅ Use stored tags
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
onTap: () => _launchApp(app),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
Made with 💚 by randogoth
|
|
|
|
Icon by [Game Icons.net](https://game-icons.net/).
|
|
""";
|