Compare commits

...

8 Commits

Author SHA1 Message Date
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
29 changed files with 448 additions and 295 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource { class FDroid extends AppSource {
@override FDroid() {
late String host = 'f-droid.org'; host = 'f-droid.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -77,13 +77,4 @@ class FDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -7,9 +7,81 @@ import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource { class GitHub extends AppSource {
@override GitHub() {
late String host = 'github.com'; host = 'github.com';
additionalDataDefaults = ['true', 'true', ''];
moreSourceSettingsFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalDataFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
canSearch = true;
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -114,72 +186,23 @@ class GitHub implements AppSource {
} }
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = [ Future<List<String>> search(String query) async {
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], Response res = await get(Uri.parse(
[ 'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
GeneratedFormItem( if (res.statusCode == 200) {
label: 'Fallback to older releases', type: FormItemType.bool) return (jsonDecode(res.body)['items'] as List<dynamic>)
], .map((e) => e['html_url'] as String)
[ .toList();
GeneratedFormItem( } else {
label: 'Filter Release Titles by Regular Expression', if (res.headers['x-ratelimit-remaining'] == '0') {
type: FormItemType.string, throw RateLimitError(
required: false, (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
additionalValidators: [ 60000000)
(value) { .round());
if (value == null || value.isEmpty) { }
return null; throw ObtainiumError(
} res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
try { unexpected: true);
RegExp(value); }
} catch (e) { }
return 'Invalid regular expression';
}
return null;
}
])
]
];
@override
List<String> additionalDataDefaults = ['true', 'true', ''];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
} }

View File

@ -1,13 +1,13 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource { class GitLab extends AppSource {
@override GitLab() {
late String host = 'gitlab.com'; host = 'gitlab.com';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -72,13 +72,4 @@ class GitLab implements AppSource {
// Same as GitHub // Same as GitHub
return GitHub().getAppNames(standardUrl); return GitHub().getAppNames(standardUrl);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource { class IzzyOnDroid extends AppSource {
@override IzzyOnDroid() {
late String host = 'android.izzysoft.de'; host = 'android.izzysoft.de';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -63,13 +63,4 @@ class IzzyOnDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource { class Mullvad extends AppSource {
@override Mullvad() {
late String host = 'mullvad.net'; host = 'mullvad.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -50,13 +50,4 @@ class Mullvad implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN'); return AppNames('Mullvad-VPN', 'Mullvad-VPN');
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,12 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource { class Signal extends AppSource {
@override Signal() {
late String host = 'signal.org'; host = 'signal.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -42,13 +42,4 @@ class Signal implements AppSource {
@override @override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource { class SourceForge extends AppSource {
@override SourceForge() {
late String host = 'sourceforge.net'; host = 'sourceforge.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -66,13 +66,4 @@ class SourceForge implements AppSource {
return AppNames(runtimeType.toString(), return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/providers/apps_provider.dart';
class ObtainiumError { class ObtainiumError {
late String message; late String message;
ObtainiumError(this.message); bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override @override
String toString() { String toString() {
return message; return message;
@ -48,10 +48,14 @@ class IDChangedError extends ObtainiumError {
: super('Downloaded package ID does not match existing App ID'); : super('Downloaded package ID does not match existing App ID');
} }
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super('This class has not implemented this function');
}
class MultiAppMultiError extends ObtainiumError { class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {}; Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder'); MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
add(String appId, String string) { add(String appId, String string) {
var tempIds = content.remove(string); var tempIds = content.remove(string);
@ -71,7 +75,7 @@ class MultiAppMultiError extends ObtainiumError {
} }
showError(dynamic e, BuildContext context) { showError(dynamic e, BuildContext context) {
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) { if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())), SnackBar(content: Text(e.toString())),
); );

View File

@ -15,7 +15,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.6.11'; const String currentVersion = '0.7.0';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -143,7 +143,8 @@ class _ObtainiumState extends State<Obtainium> {
[], [],
0, 0,
['true'], ['true'],
null) null,
false)
]); ]);
} }
// Register the background update task according to the user's setting // Register the background update task according to the user's setting

View File

@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<App> selectedApps = {};
DateTime? refreshingSince; DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
setState(() { setState(() {
selectedIds.clear(); selectedApps.clear();
}); });
return true; return true;
} }
return false; return false;
} }
selectThese(List<String> appIds) { selectThese(List<App> apps) {
if (selectedIds.isEmpty) { if (selectedApps.isEmpty) {
setState(() { setState(() {
for (var a in appIds) { for (var a in apps) {
selectedIds.add(a); selectedApps.add(a);
} }
}); });
} }
@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly = var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false; filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet(); .toSet();
toggleAppSelected(String appId) { toggleAppSelected(App app) {
setState(() { setState(() {
if (selectedIds.contains(appId)) { if (selectedApps.contains(app)) {
selectedIds.remove(appId); selectedApps.remove(app);
} else { } else {
selectedIds.add(appId); selectedApps.add(app);
} }
}); });
} }
@ -124,15 +124,15 @@ class AppsPageState extends State<AppsPage> {
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
var newInstallIdsAllOrSelected = appsProvider var newInstallIdsAllOrSelected = appsProvider
.findExistingUpdates(nonInstalledOnly: true) .findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
if (settingsProvider.pinUpdates) { if (settingsProvider.pinUpdates) {
@ -147,6 +147,17 @@ class AppsPageState extends State<AppsPage> {
sortedApps = [...temp, ...sortedApps]; 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
@ -192,11 +203,16 @@ class AppsPageState extends State<AppsPage> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return ListTile( return ListTile(
selectedTileColor: tileColor: sortedApps[index].app.pinned
Theme.of(context).colorScheme.primary.withOpacity(0.1), ? Colors.grey.withOpacity(0.1)
selected: selectedIds.contains(sortedApps[index].app.id), : Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () { onLongPress: () {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
}, },
leading: sortedApps[index].installedInfo != null leading: sortedApps[index].installedInfo != null
? Image.memory( ? Image.memory(
@ -204,9 +220,19 @@ class AppsPageState extends State<AppsPage> {
gaplessPlayback: true, gaplessPlayback: true,
) )
: null, : null,
title: Text(sortedApps[index].installedInfo?.name ?? title: Text(
sortedApps[index].app.name), sortedApps[index].installedInfo?.name ??
subtitle: Text('By ${sortedApps[index].app.author}'), 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 trailing: sortedApps[index].downloadProgress != null
? Text( ? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
@ -256,8 +282,8 @@ class AppsPageState extends State<AppsPage> {
textAlign: TextAlign.end, textAlign: TextAlign.end,
)))), )))),
onTap: () { onTap: () {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
} else { } else {
Navigator.push( Navigator.push(
context, context,
@ -275,25 +301,25 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
selectedIds.isEmpty selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList()) ? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected(); : clearSelected();
}, },
icon: Icon( icon: Icon(
selectedIds.isEmpty selectedApps.isEmpty
? Icons.select_all_outlined ? Icons.select_all_outlined
: Icons.deselect_outlined, : Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
tooltip: selectedIds.isEmpty tooltip: selectedApps.isEmpty
? 'Select All' ? 'Select All'
: 'Deselect ${selectedIds.length.toString()}'), : 'Deselect ${selectedApps.length.toString()}'),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -307,11 +333,12 @@ class AppsPageState extends State<AppsPage> {
defaultValues: const [], defaultValues: const [],
initValid: true, initValid: true,
message: 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) { }).then((values) {
if (values != null) { 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) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', 'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
message: message:
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs, items: formInputs,
@ -386,11 +413,11 @@ class AppsPageState extends State<AppsPage> {
}); });
}, },
tooltip: tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', 'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon( icon: const Icon(
Icons.file_download_outlined, Icons.file_download_outlined,
)), )),
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -419,7 +446,7 @@ class AppsPageState extends State<AppsPage> {
ctx) { ctx) {
return AlertDialog( return AlertDialog(
title: Text( title: Text(
'Mark ${selectedIds.length} Selected Apps as Updated?'), 'Mark ${selectedApps.length} Selected Apps as Updated?'),
content: content:
const Text( const Text(
'Only applies to installed but out of date Apps.'), 'Only applies to installed but out of date Apps.'),
@ -438,9 +465,7 @@ class AppsPageState extends State<AppsPage> {
HapticFeedback HapticFeedback
.selectionClick(); .selectionClick();
appsProvider appsProvider
.saveApps(selectedIds.map((e) { .saveApps(selectedApps.map((a) {
var a =
appsProvider.apps[e]!.app;
if (a.installedVersion != if (a.installedVersion !=
null) { null) {
a.installedVersion = a.latestVersion; a.installedVersion = a.latestVersion;
@ -455,23 +480,50 @@ class AppsPageState extends State<AppsPage> {
'Yes')) 'Yes'))
], ],
); );
}); }).whenComplete(() {
Navigator.of(
context)
.pop();
});
}, },
tooltip: tooltip:
'Mark Selected Apps as Updated', 'Mark Selected Apps as Updated',
icon: const Icon(Icons.done)), 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( IconButton(
onPressed: () { onPressed: () {
String urls = ''; String urls = '';
for (var id in selectedIds) { for (var a in selectedApps) {
urls += urls += '${a.url}\n';
'${appsProvider.apps[id]!.app.url}\n';
} }
urls = urls.substring( urls = urls.substring(
0, urls.length - 1); 0, urls.length - 1);
Share.share(urls, Share.share(urls,
subject: subject:
'${selectedIds.length} Selected App URLs from Obtainium'); '${selectedApps.length} Selected App URLs from Obtainium');
Navigator.of(context).pop();
}, },
tooltip: 'Share Selected App URLs', tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share), icon: const Icon(Icons.share),

View File

@ -223,6 +223,103 @@ class _ImportExportPageState extends State<ImportExportPage> {
child: const Text( child: const Text(
'Import from URL List', 'Import from URL List',
)), )),
...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog<
List<String>>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Search ${source.runtimeType}',
items: [
[
GeneratedFormItem(
label:
'${source.runtimeType} Search Query')
]
],
defaultValues: const [],
);
});
if (values != null &&
values[0].isNotEmpty) {
setState(() {
importInProgress = true;
});
var urls = await source
.search(values[0]);
if (urls.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urls: urls,
defaultSelected:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await addApps(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
'No results found');
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
'Search ${source.runtimeType}'))
]))
.toList(),
...sourceProvider.massUrlSources ...sourceProvider.massUrlSources
.map((source) => Column( .map((source) => Column(
crossAxisAlignment: crossAxisAlignment:
@ -233,84 +330,73 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
showDialog( () async {
context: context, var values = await showDialog(
builder: context: context,
(BuildContext ctx) { builder:
return GeneratedFormModal( (BuildContext ctx) {
title: return GeneratedFormModal(
'Import ${source.name}', title:
items: source 'Import ${source.name}',
.requiredArgs items:
.map((e) => [ source
GeneratedFormItem( .requiredArgs
label: e) .map(
]) (e) => [
.toList(), GeneratedFormItem(label: e)
defaultValues: const [], ])
); .toList(),
}).then((values) { defaultValues: const [],
);
});
if (values != null) { if (values != null) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
source var urls = await source
.getUrls(values) .getUrls(values);
.then((urls) { var selectedUrls =
showDialog<List<String>?>( await showDialog<
context: context, List<String>?>(
builder: context: context,
(BuildContext builder:
ctx) { (BuildContext
return UrlSelectionModal( ctx) {
urls: urls); return UrlSelectionModal(
}) urls: urls);
.then((selectedUrls) {
if (selectedUrls !=
null) {
addApps(selectedUrls)
.then((errors) {
if (errors
.isEmpty) {
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context:
context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
}); });
}); if (selectedUrls != null) {
} else { var errors =
setState(() { await addApps(
importInProgress = selectedUrls);
false; if (errors.isEmpty) {
}); // ignore: use_build_context_synchronously
} showError(
}); 'Imported ${selectedUrls.length} Apps',
}).catchError((e) { context);
setState(() { } else {
importInProgress = showDialog(
false; context: context,
}); builder:
showError(e, context); (BuildContext
}); ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} }
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
}, },
child: Text('Import ${source.name}')) child: Text('Import ${source.name}'))
@ -390,9 +476,11 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
// ignore: must_be_immutable // ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget { class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls}); UrlSelectionModal(
{super.key, required this.urls, this.defaultSelected = true});
List<String> urls; List<String> urls;
bool defaultSelected;
@override @override
State<UrlSelectionModal> createState() => _UrlSelectionModalState(); State<UrlSelectionModal> createState() => _UrlSelectionModalState();
@ -404,7 +492,7 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
void initState() { void initState() {
super.initState(); super.initState();
for (var url in widget.urls) { for (var url in widget.urls) {
urlSelections.putIfAbsent(url, () => true); urlSelections.putIfAbsent(url, () => widget.defaultSelected);
} }
} }

View File

@ -490,7 +490,8 @@ class AppsProvider with ChangeNotifier {
currentApp.url, currentApp.url,
currentApp.additionalData, currentApp.additionalData,
name: currentApp.name, name: currentApp.name,
id: currentApp.id); id: currentApp.id,
pinned: currentApp.pinned);
newApp.installedVersion = currentApp.installedVersion; newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;

View File

@ -40,6 +40,7 @@ class App {
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false;
App( App(
this.id, this.id,
this.url, this.url,
@ -50,11 +51,12 @@ class App {
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck); this.lastUpdateCheck,
this.pinned);
@override @override
String toString() { 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( factory App.fromJson(Map<String, dynamic> json) => App(
@ -75,7 +77,8 @@ class App {
: List<String>.from(jsonDecode(json['additionalData'])), : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -87,7 +90,8 @@ class App {
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData), 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned
}; };
} }
@ -128,17 +132,36 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
abstract class AppSource { class AppSource {
late String host; late String host;
String standardizeURL(String url); String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData); String standardUrl, List<String> additionalData) {
AppNames getAppNames(String standardUrl); throw NotImplementedError();
late List<List<GeneratedFormItem>> additionalDataFormItems; }
late List<String> additionalDataDefaults;
late List<GeneratedFormItem> moreSourceSettingsFormItems; AppNames getAppNames(String standardUrl) {
String? changeLogPageFromStandardUrl(String standardUrl); throw NotImplementedError();
Future<String> apkUrlPrefetchModifier(String apkUrl); }
List<List<GeneratedFormItem>> additionalDataFormItems = [];
List<String> additionalDataDefaults = [];
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) {
throw NotImplementedError();
}
bool canSearch = false;
Future<List<String>> search(String query) {
throw NotImplementedError();
}
} }
abstract class MassAppUrlSource { abstract class MassAppUrlSource {
@ -205,7 +228,7 @@ class SourceProvider {
} }
Future<App> getApp(AppSource source, String url, List<String> additionalData, 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)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = APKDetails apk =
@ -222,7 +245,8 @@ class SourceProvider {
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now()); DateTime.now(),
pinned);
} }
// Returns errors in [results, errors] instead of throwing them // Returns errors in [results, errors] instead of throwing them

View File

@ -21,7 +21,7 @@ packages:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.2" version: "3.3.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -78,6 +78,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@ -173,7 +180,7 @@ packages:
name: flutter_fgbg name: flutter_fgbg
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -457,6 +464,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -706,7 +720,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.1.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.6.11+55 # When changing this, update the tag in main() accordingly version: 0.7.0+56 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'