mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-17 15:16:43 +02:00
Added App pinning
This commit is contained in:
@ -1,6 +1,5 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
|
@ -143,7 +143,8 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
[],
|
||||
0,
|
||||
['true'],
|
||||
null)
|
||||
null,
|
||||
false)
|
||||
]);
|
||||
}
|
||||
// Register the background update task according to the user's setting
|
||||
|
@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> {
|
||||
AppsFilter? filter;
|
||||
var updatesOnlyFilter =
|
||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||
Set<String> selectedIds = {};
|
||||
Set<App> selectedApps = {};
|
||||
DateTime? refreshingSince;
|
||||
|
||||
clearSelected() {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
if (selectedApps.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIds.clear();
|
||||
selectedApps.clear();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
selectThese(List<String> appIds) {
|
||||
if (selectedIds.isEmpty) {
|
||||
selectThese(List<App> apps) {
|
||||
if (selectedApps.isEmpty) {
|
||||
setState(() {
|
||||
for (var a in appIds) {
|
||||
selectedIds.add(a);
|
||||
for (var a in apps) {
|
||||
selectedApps.add(a);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
var currentFilterIsUpdatesOnly =
|
||||
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||
|
||||
selectedIds = selectedIds
|
||||
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
|
||||
selectedApps = selectedApps
|
||||
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||
.toSet();
|
||||
|
||||
toggleAppSelected(String appId) {
|
||||
toggleAppSelected(App app) {
|
||||
setState(() {
|
||||
if (selectedIds.contains(appId)) {
|
||||
selectedIds.remove(appId);
|
||||
if (selectedApps.contains(app)) {
|
||||
selectedApps.remove(app);
|
||||
} else {
|
||||
selectedIds.add(appId);
|
||||
selectedApps.add(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -124,15 +124,15 @@ class AppsPageState extends State<AppsPage> {
|
||||
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||
|
||||
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
var newInstallIdsAllOrSelected = appsProvider
|
||||
.findExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
|
||||
if (settingsProvider.pinUpdates) {
|
||||
@ -147,6 +147,17 @@ class AppsPageState extends State<AppsPage> {
|
||||
sortedApps = [...temp, ...sortedApps];
|
||||
}
|
||||
|
||||
var tempPinned = [];
|
||||
var tempNotPinned = [];
|
||||
for (var a in sortedApps) {
|
||||
if (a.app.pinned) {
|
||||
tempPinned.add(a);
|
||||
} else {
|
||||
tempNotPinned.add(a);
|
||||
}
|
||||
}
|
||||
sortedApps = [...tempPinned, ...tempNotPinned];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
@ -192,11 +203,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return ListTile(
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||
tileColor: sortedApps[index].app.pinned
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||
selected: selectedApps.contains(sortedApps[index].app),
|
||||
onLongPress: () {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
},
|
||||
leading: sortedApps[index].installedInfo != null
|
||||
? Image.memory(
|
||||
@ -204,9 +220,19 @@ class AppsPageState extends State<AppsPage> {
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: null,
|
||||
title: Text(sortedApps[index].installedInfo?.name ??
|
||||
sortedApps[index].app.name),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||
title: Text(
|
||||
sortedApps[index].installedInfo?.name ??
|
||||
sortedApps[index].app.name,
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal),
|
||||
),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}',
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal)),
|
||||
trailing: sortedApps[index].downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||
@ -256,8 +282,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
textAlign: TextAlign.end,
|
||||
)))),
|
||||
onTap: () {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
if (selectedApps.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
@ -275,25 +301,25 @@ class AppsPageState extends State<AppsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
selectedIds.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app.id).toList())
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
: clearSelected();
|
||||
},
|
||||
icon: Icon(
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? Icons.select_all_outlined
|
||||
: Icons.deselect_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: selectedIds.isEmpty
|
||||
tooltip: selectedApps.isEmpty
|
||||
? 'Select All'
|
||||
: 'Deselect ${selectedIds.length.toString()}'),
|
||||
: 'Deselect ${selectedApps.length.toString()}'),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -307,11 +333,12 @@ class AppsPageState extends State<AppsPage> {
|
||||
defaultValues: const [],
|
||||
initValid: true,
|
||||
message:
|
||||
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
|
||||
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(selectedIds.toList());
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -347,7 +374,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
message:
|
||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||
items: formInputs,
|
||||
@ -386,11 +413,11 @@ class AppsPageState extends State<AppsPage> {
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -419,7 +446,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Updated?'),
|
||||
'Mark ${selectedApps.length} Selected Apps as Updated?'),
|
||||
content:
|
||||
const Text(
|
||||
'Only applies to installed but out of date Apps.'),
|
||||
@ -438,9 +465,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
@ -455,23 +480,50 @@ class AppsPageState extends State<AppsPage> {
|
||||
'Yes'))
|
||||
],
|
||||
);
|
||||
});
|
||||
}).whenComplete(() {
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
var pinStatus = selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty;
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.pinned = pinStatus;
|
||||
return e;
|
||||
}).toList());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip:
|
||||
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
|
||||
icon: Icon(selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty
|
||||
? Icons.bookmark_outline_rounded
|
||||
: Icons
|
||||
.bookmark_remove_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
String urls = '';
|
||||
for (var id in selectedIds) {
|
||||
urls +=
|
||||
'${appsProvider.apps[id]!.app.url}\n';
|
||||
for (var a in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
0, urls.length - 1);
|
||||
Share.share(urls,
|
||||
subject:
|
||||
'${selectedIds.length} Selected App URLs from Obtainium');
|
||||
'${selectedApps.length} Selected App URLs from Obtainium');
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip: 'Share Selected App URLs',
|
||||
icon: const Icon(Icons.share),
|
||||
|
@ -490,7 +490,8 @@ class AppsProvider with ChangeNotifier {
|
||||
currentApp.url,
|
||||
currentApp.additionalData,
|
||||
name: currentApp.name,
|
||||
id: currentApp.id);
|
||||
id: currentApp.id,
|
||||
pinned: currentApp.pinned);
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||
|
@ -40,6 +40,7 @@ class App {
|
||||
late int preferredApkIndex;
|
||||
late List<String> additionalData;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -50,11 +51,12 @@ class App {
|
||||
this.apkUrls,
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.lastUpdateCheck);
|
||||
this.lastUpdateCheck,
|
||||
this.pinned);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
@ -75,7 +77,8 @@ class App {
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -87,7 +90,8 @@ class App {
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned
|
||||
};
|
||||
}
|
||||
|
||||
@ -224,7 +228,7 @@ class SourceProvider {
|
||||
}
|
||||
|
||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||
{String name = '', String? id}) async {
|
||||
{String name = '', String? id, bool pinned = false}) async {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk =
|
||||
@ -241,7 +245,8 @@ class SourceProvider {
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
DateTime.now());
|
||||
DateTime.now(),
|
||||
pinned);
|
||||
}
|
||||
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
|
Reference in New Issue
Block a user