mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 21:36:42 +02:00
Compare commits
13 Commits
v0.2.2-bet
...
v0.3.2-bet
Author | SHA1 | Date | |
---|---|---|---|
22dd8253a9 | |||
18198bbdfe | |||
cf3c86abb8 | |||
570e376742 | |||
32ae5e8175 | |||
cbf5057c17 | |||
2cfe62142a | |||
d03486fc5d | |||
224e435bbb | |||
90fa0e06ce | |||
6c1ad94b4f | |||
7d7986f8bf | |||
3ddf9ea736 |
57
lib/app_sources/fdroid.dart
Normal file
57
lib/app_sources/fdroid.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class FDroid implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'f-droid.org';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse(standardUrl));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var latestReleaseDiv =
|
||||||
|
parse(res.body).querySelector('#latest.package-version');
|
||||||
|
var apkUrl = latestReleaseDiv
|
||||||
|
?.querySelector('.package-version-download a')
|
||||||
|
?.attributes['href'];
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
var version = latestReleaseDiv
|
||||||
|
?.querySelector('.package-version-header b')
|
||||||
|
?.innerHtml
|
||||||
|
.split(' ')
|
||||||
|
.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [apkUrl]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
}
|
123
lib/app_sources/github.dart
Normal file
123
lib/app_sources/github.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class GitHub implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'github.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
var includePrereleases =
|
||||||
|
additionalData.isNotEmpty && additionalData[0] == "true";
|
||||||
|
var fallbackToOlderReleases =
|
||||||
|
additionalData.length >= 2 && additionalData[1] == "true";
|
||||||
|
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
|
||||||
|
? additionalData[2]
|
||||||
|
: null;
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||||
|
|
||||||
|
List<String> getReleaseAPKUrls(dynamic release) =>
|
||||||
|
(release['assets'] as List<dynamic>?)
|
||||||
|
?.map((e) {
|
||||||
|
return e['browser_download_url'] != null
|
||||||
|
? e['browser_download_url'] as String
|
||||||
|
: '';
|
||||||
|
})
|
||||||
|
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
dynamic targetRelease;
|
||||||
|
|
||||||
|
for (int i = 0; i < releases.length; i++) {
|
||||||
|
if (!fallbackToOlderReleases && i > 0) break;
|
||||||
|
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (regexFilter != null &&
|
||||||
|
!RegExp(regexFilter)
|
||||||
|
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||||
|
if (apkUrls.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetRelease = releases[i];
|
||||||
|
targetRelease['apkUrls'] = apkUrls;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (targetRelease == null) {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
String? version = targetRelease['tag_name'];
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, targetRelease['apkUrls']);
|
||||||
|
} else {
|
||||||
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
|
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||||
|
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||||
|
return AppNames(names[0], names[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> 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;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = ["true", "true", ""];
|
||||||
|
}
|
71
lib/app_sources/gitlab.dart
Normal file
71
lib/app_sources/gitlab.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class GitLab implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'gitlab.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var standardUri = Uri.parse(standardUrl);
|
||||||
|
var parsedHtml = parse(res.body);
|
||||||
|
var entry = parsedHtml.querySelector('entry');
|
||||||
|
var entryContent =
|
||||||
|
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||||
|
var apkUrlList = [
|
||||||
|
...getLinksFromParsedHTML(
|
||||||
|
entryContent,
|
||||||
|
RegExp(
|
||||||
|
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||||
|
caseSensitive: false),
|
||||||
|
standardUri.origin),
|
||||||
|
// GitLab releases may contain links to externally hosted APKs
|
||||||
|
...getLinksFromParsedHTML(entryContent,
|
||||||
|
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||||
|
.where((element) => Uri.parse(element).host != '')
|
||||||
|
.toList()
|
||||||
|
];
|
||||||
|
if (apkUrlList.isEmpty) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
|
var version =
|
||||||
|
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrlList);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
// Same as GitHub
|
||||||
|
return GitHub().getAppNames(standardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
}
|
65
lib/app_sources/izzyondroid.dart
Normal file
65
lib/app_sources/izzyondroid.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class IzzyOnDroid implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'android.izzysoft.de';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse(standardUrl));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var parsedHtml = parse(res.body);
|
||||||
|
var multipleVersionApkUrls = parsedHtml
|
||||||
|
.querySelectorAll('a')
|
||||||
|
.where((element) =>
|
||||||
|
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||||
|
false)
|
||||||
|
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||||
|
.toList();
|
||||||
|
if (multipleVersionApkUrls.isEmpty) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
var version = parsedHtml
|
||||||
|
.querySelector('#keydata')
|
||||||
|
?.querySelectorAll('b')
|
||||||
|
.where(
|
||||||
|
(element) => element.innerHtml.toLowerCase().contains('version'))
|
||||||
|
.toList()[0]
|
||||||
|
.parentNode
|
||||||
|
?.parentNode
|
||||||
|
?.children[1]
|
||||||
|
.innerHtml;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
}
|
51
lib/app_sources/mullvad.dart
Normal file
51
lib/app_sources/mullvad.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class Mullvad implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'mullvad.net';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var version = parse(res.body)
|
||||||
|
.querySelector('p.subtitle.is-6')
|
||||||
|
?.querySelector('a')
|
||||||
|
?.attributes['href']
|
||||||
|
?.split('/')
|
||||||
|
.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(
|
||||||
|
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
}
|
44
lib/app_sources/signal.dart
Normal file
44
lib/app_sources/signal.dart
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class Signal implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'signal.org';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
return 'https://$host';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res =
|
||||||
|
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var json = jsonDecode(res.body);
|
||||||
|
String? apkUrl = json['url'];
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
String? version = json['versionName'];
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [apkUrl]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
}
|
175
lib/components/generated_form.dart
Normal file
175
lib/components/generated_form.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum FormItemType { string, bool }
|
||||||
|
|
||||||
|
typedef OnValueChanges = void Function(List<String> values, bool valid);
|
||||||
|
|
||||||
|
class GeneratedFormItem {
|
||||||
|
late String label;
|
||||||
|
late FormItemType type;
|
||||||
|
late bool required;
|
||||||
|
late int max;
|
||||||
|
late List<String? Function(String? value)> additionalValidators;
|
||||||
|
|
||||||
|
GeneratedFormItem(
|
||||||
|
{this.label = "Input",
|
||||||
|
this.type = FormItemType.string,
|
||||||
|
this.required = true,
|
||||||
|
this.max = 1,
|
||||||
|
this.additionalValidators = const []});
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedForm extends StatefulWidget {
|
||||||
|
const GeneratedForm(
|
||||||
|
{super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.onValueChanges,
|
||||||
|
required this.defaultValues});
|
||||||
|
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final OnValueChanges onValueChanges;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneratedFormState extends State<GeneratedForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late List<List<String>> values;
|
||||||
|
late List<List<Widget>> formInputs;
|
||||||
|
List<List<Widget>> rows = [];
|
||||||
|
|
||||||
|
// If any value changes, call this to update the parent with value and validity
|
||||||
|
void someValueChanged() {
|
||||||
|
List<String> returnValues = [];
|
||||||
|
var valid = true;
|
||||||
|
for (int r = 0; r < values.length; r++) {
|
||||||
|
for (int i = 0; i < values[r].length; i++) {
|
||||||
|
returnValues.add(values[r][i]);
|
||||||
|
if (formInputs[r][i] is TextFormField) {
|
||||||
|
valid = valid &&
|
||||||
|
((formInputs[r][i].key as GlobalKey<FormFieldState>)
|
||||||
|
.currentState
|
||||||
|
?.isValid ??
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.onValueChanges(returnValues, valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize form values as all empty
|
||||||
|
int j = 0;
|
||||||
|
values = widget.items
|
||||||
|
.map((row) => row.map((e) {
|
||||||
|
return j < widget.defaultValues.length
|
||||||
|
? widget.defaultValues[j++]
|
||||||
|
: "";
|
||||||
|
}).toList())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Dynamically create form inputs
|
||||||
|
formInputs = widget.items.asMap().entries.map((row) {
|
||||||
|
return row.value.asMap().entries.map((e) {
|
||||||
|
if (e.value.type == FormItemType.string) {
|
||||||
|
final formFieldKey = GlobalKey<FormFieldState>();
|
||||||
|
return TextFormField(
|
||||||
|
key: formFieldKey,
|
||||||
|
initialValue: values[row.key][e.key],
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[row.key][e.key] = value;
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
helperText: e.value.label + (e.value.required ? " *" : "")),
|
||||||
|
minLines: e.value.max <= 1 ? null : e.value.max,
|
||||||
|
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||||
|
validator: (value) {
|
||||||
|
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||||
|
return '${e.value.label} (required)';
|
||||||
|
}
|
||||||
|
for (var validator in e.value.additionalValidators) {
|
||||||
|
String? result = validator(value);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container(); // Some input types added in build
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
for (var r = 0; r < formInputs.length; r++) {
|
||||||
|
for (var e = 0; e < formInputs[r].length; e++) {
|
||||||
|
if (widget.items[r][e].type == FormItemType.bool) {
|
||||||
|
formInputs[r][e] = Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.items[r][e].label),
|
||||||
|
Switch(
|
||||||
|
value: values[r][e] == "true",
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[r][e] = value ? "true" : "";
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.clear();
|
||||||
|
formInputs.asMap().entries.forEach((rowInputs) {
|
||||||
|
if (rowInputs.key > 0) {
|
||||||
|
rows.add([
|
||||||
|
SizedBox(
|
||||||
|
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
|
||||||
|
widget.items[rowInputs.key - 1][0].type ==
|
||||||
|
FormItemType.string
|
||||||
|
? 25
|
||||||
|
: 8,
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
List<Widget> rowItems = [];
|
||||||
|
rowInputs.value.asMap().entries.forEach((rowInput) {
|
||||||
|
if (rowInput.key > 0) {
|
||||||
|
rowItems.add(const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rowItems.add(Expanded(child: rowInput.value));
|
||||||
|
});
|
||||||
|
rows.add(rowItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
...rows.map((row) => Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [...row.map((e) => e)],
|
||||||
|
))
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
@ -1,61 +1,40 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
class GeneratedFormItem {
|
|
||||||
late String message;
|
|
||||||
late bool required;
|
|
||||||
late int lines;
|
|
||||||
|
|
||||||
GeneratedFormItem(this.message, this.required, this.lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GeneratedFormModal extends StatefulWidget {
|
class GeneratedFormModal extends StatefulWidget {
|
||||||
const GeneratedFormModal(
|
const GeneratedFormModal(
|
||||||
{super.key, required this.title, required this.items});
|
{super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
required this.defaultValues});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final List<GeneratedFormItem> items;
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
List<String> values = [];
|
||||||
|
bool valid = false;
|
||||||
final urlInputController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final formInputs = widget.items.map((e) {
|
|
||||||
final controller = TextEditingController();
|
|
||||||
return [
|
|
||||||
controller,
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(helperText: e.message),
|
|
||||||
controller: controller,
|
|
||||||
minLines: e.lines <= 1 ? null : e.lines,
|
|
||||||
maxLines: e.lines <= 1 ? 1 : e.lines,
|
|
||||||
validator: e.required
|
|
||||||
? (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return '${e.message} (required)';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}).toList();
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: Text(widget.title),
|
title: Text(widget.title),
|
||||||
content: Form(
|
content: GeneratedForm(
|
||||||
key: _formKey,
|
items: widget.items,
|
||||||
child: Column(
|
onValueChanges: (values, valid) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
setState(() {
|
||||||
children: [...formInputs.map((e) => e[1] as Widget)],
|
this.values = values;
|
||||||
)),
|
this.valid = valid;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
defaultValues: widget.defaultValues),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@ -63,18 +42,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: const Text('Cancel')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: !valid
|
||||||
if (_formKey.currentState?.validate() == true) {
|
? null
|
||||||
HapticFeedback.selectionClick();
|
: () {
|
||||||
Navigator.of(context).pop(formInputs
|
if (valid) {
|
||||||
.map((e) => (e[0] as TextEditingController).value.text)
|
HapticFeedback.selectionClick();
|
||||||
.toList());
|
Navigator.of(context).pop(values);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: const Text('Continue'))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add support for larger textarea so this can be used for text/json imports
|
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:obtainium/pages/home.dart';
|
import 'package:obtainium/pages/home.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
@ -12,7 +13,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';
|
||||||
|
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.2.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v0.3.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
void bgTaskCallback() {
|
||||||
@ -58,7 +59,7 @@ void main() async {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => AppsProvider(
|
create: (context) => AppsProvider(
|
||||||
shouldLoadApps: true,
|
shouldLoadApps: true,
|
||||||
shouldCheckUpdatesAfterLoad: true,
|
shouldCheckUpdatesAfterLoad: false,
|
||||||
shouldDeleteAPKs: true)),
|
shouldDeleteAPKs: true)),
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider())
|
||||||
@ -102,7 +103,8 @@ class MyApp extends StatelessWidget {
|
|||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
[],
|
[],
|
||||||
0));
|
0,
|
||||||
|
["true"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
32
lib/mass_app_sources/githubstars.dart
Normal file
32
lib/mass_app_sources/githubstars.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class GitHubStars implements MassAppSource {
|
||||||
|
@override
|
||||||
|
late String name = 'GitHub Starred Repos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
late List<String> requiredArgs = ['Username'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getUrls(List<String> args) async {
|
||||||
|
if (args.length != requiredArgs.length) {
|
||||||
|
throw 'Wrong number of arguments provided';
|
||||||
|
}
|
||||||
|
Response res =
|
||||||
|
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
return (jsonDecode(res.body) as List<dynamic>)
|
||||||
|
.map((e) => e['html_url'] as String)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
|
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'Unable to find user\'s starred repos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -16,132 +17,187 @@ class AddAppPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddAppPageState extends State<AddAppPage> {
|
class _AddAppPageState extends State<AddAppPage> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final urlInputController = TextEditingController();
|
|
||||||
bool gettingAppInfo = false;
|
bool gettingAppInfo = false;
|
||||||
|
|
||||||
|
String userInput = "";
|
||||||
|
AppSource? pickedSource;
|
||||||
|
List<String> additionalData = [];
|
||||||
|
bool validAdditionalData = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
return CustomScrollView(slivers: <Widget>[
|
return Scaffold(
|
||||||
const CustomAppBar(title: 'Add App'),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
SliverFillRemaining(
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
hasScrollBody: false,
|
const CustomAppBar(title: 'Add App'),
|
||||||
child: Center(
|
SliverFillRemaining(
|
||||||
child: Form(
|
child: Padding(
|
||||||
key: _formKey,
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Container(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
Expanded(
|
||||||
decoration: const InputDecoration(
|
child: GeneratedForm(
|
||||||
hintText: 'https://github.com/Author/Project',
|
items: [
|
||||||
helperText: 'Enter the App source URL'),
|
[
|
||||||
controller: urlInputController,
|
GeneratedFormItem(
|
||||||
validator: (value) {
|
label: "App Source Url",
|
||||||
if (value == null ||
|
additionalValidators: [
|
||||||
value.isEmpty ||
|
(value) {
|
||||||
Uri.tryParse(value) == null) {
|
try {
|
||||||
return 'Please enter a supported source URL';
|
sourceProvider
|
||||||
}
|
.getSource(value ?? "")
|
||||||
return null;
|
.standardizeURL(
|
||||||
},
|
makeUrlHttps(
|
||||||
|
value ?? ""));
|
||||||
|
} catch (e) {
|
||||||
|
return e is String
|
||||||
|
? e
|
||||||
|
: "Error";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
onValueChanges: (values, valid) {
|
||||||
|
setState(() {
|
||||||
|
userInput = values[0];
|
||||||
|
var source = valid
|
||||||
|
? sourceProvider.getSource(userInput)
|
||||||
|
: null;
|
||||||
|
if (pickedSource != source) {
|
||||||
|
pickedSource = source;
|
||||||
|
additionalData = source != null
|
||||||
|
? source.additionalDataDefaults
|
||||||
|
: [];
|
||||||
|
validAdditionalData = source != null
|
||||||
|
? sourceProvider
|
||||||
|
.doesSourceHaveRequiredAdditionalData(
|
||||||
|
source)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
defaultValues: const [])),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
),
|
),
|
||||||
Padding(
|
ElevatedButton(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
onPressed: gettingAppInfo ||
|
||||||
child: ElevatedButton(
|
pickedSource == null ||
|
||||||
onPressed: gettingAppInfo
|
(pickedSource!.additionalDataFormItems
|
||||||
|
.isNotEmpty &&
|
||||||
|
!validAdditionalData)
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
if (_formKey.currentState!.validate()) {
|
setState(() {
|
||||||
|
gettingAppInfo = true;
|
||||||
|
});
|
||||||
|
sourceProvider
|
||||||
|
.getApp(pickedSource!, userInput,
|
||||||
|
additionalData)
|
||||||
|
.then((app) {
|
||||||
|
var appsProvider =
|
||||||
|
context.read<AppsProvider>();
|
||||||
|
var settingsProvider =
|
||||||
|
context.read<SettingsProvider>();
|
||||||
|
if (appsProvider.apps
|
||||||
|
.containsKey(app.id)) {
|
||||||
|
throw 'App already added';
|
||||||
|
}
|
||||||
|
settingsProvider
|
||||||
|
.getInstallPermission()
|
||||||
|
.then((_) {
|
||||||
|
appsProvider.saveApp(app).then((_) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(
|
||||||
|
appId: app.id)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
setState(() {
|
setState(() {
|
||||||
gettingAppInfo = true;
|
gettingAppInfo = false;
|
||||||
});
|
});
|
||||||
sourceProvider
|
});
|
||||||
.getApp(
|
|
||||||
urlInputController.value.text)
|
|
||||||
.then((app) {
|
|
||||||
var appsProvider =
|
|
||||||
context.read<AppsProvider>();
|
|
||||||
var settingsProvider =
|
|
||||||
context.read<SettingsProvider>();
|
|
||||||
if (appsProvider.apps
|
|
||||||
.containsKey(app.id)) {
|
|
||||||
throw 'App already added';
|
|
||||||
}
|
|
||||||
settingsProvider
|
|
||||||
.getInstallPermission()
|
|
||||||
.then((_) {
|
|
||||||
appsProvider.saveApp(app).then((_) {
|
|
||||||
urlInputController.clear();
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
AppPage(
|
|
||||||
appId: app.id)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
gettingAppInfo = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: const Text('Add'),
|
child: const Text('Add'))
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
if (pickedSource != null &&
|
||||||
Column(
|
(pickedSource!.additionalDataFormItems.isNotEmpty))
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Text(
|
children: [
|
||||||
'Supported Sources:',
|
const Divider(
|
||||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
height: 64,
|
||||||
// style: Theme.of(context).textTheme.bodySmall,
|
),
|
||||||
),
|
Text(
|
||||||
const SizedBox(
|
'Additional Options for ${pickedSource?.runtimeType}',
|
||||||
height: 8,
|
style: TextStyle(
|
||||||
),
|
color:
|
||||||
...sourceProvider
|
Theme.of(context).colorScheme.primary)),
|
||||||
.getSourceHosts()
|
const SizedBox(
|
||||||
.map((e) => GestureDetector(
|
height: 16,
|
||||||
onTap: () {
|
),
|
||||||
launchUrlString('https://$e',
|
GeneratedForm(
|
||||||
mode: LaunchMode.externalApplication);
|
items: pickedSource!.additionalDataFormItems,
|
||||||
},
|
onValueChanges: (values, valid) {
|
||||||
child: Text(
|
setState(() {
|
||||||
e,
|
additionalData = values;
|
||||||
style: const TextStyle(
|
validAdditionalData = valid;
|
||||||
decoration: TextDecoration.underline,
|
});
|
||||||
fontStyle: FontStyle.italic),
|
},
|
||||||
)))
|
defaultValues:
|
||||||
.toList()
|
pickedSource!.additionalDataDefaults)
|
||||||
]),
|
],
|
||||||
if (gettingAppInfo)
|
)
|
||||||
const LinearProgressIndicator()
|
else
|
||||||
else
|
Expanded(
|
||||||
Container(),
|
child: Column(
|
||||||
],
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
)),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
))
|
children: [
|
||||||
]);
|
// const SizedBox(
|
||||||
|
// height: 48,
|
||||||
|
// ),
|
||||||
|
const Text(
|
||||||
|
'Supported Sources:',
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
...sourceProvider
|
||||||
|
.getSourceHosts()
|
||||||
|
.map((e) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://$e',
|
||||||
|
mode:
|
||||||
|
LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
e,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic),
|
||||||
|
)))
|
||||||
|
.toList()
|
||||||
|
])),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_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';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -21,69 +22,67 @@ class _AppPageState extends State<AppPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.watch<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
var sourceProvider = SourceProvider();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
|
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||||
if (app?.app.installedVersion != null) {
|
if (app?.app.installedVersion != null) {
|
||||||
appsProvider.getUpdate(app!.app.id);
|
appsProvider.getUpdate(app!.app.id);
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: settingsProvider.showAppWebpage
|
||||||
CustomAppBar(title: '${app?.app.name}'),
|
? WebView(
|
||||||
SliverFillRemaining(
|
initialUrl: app?.app.url,
|
||||||
child: settingsProvider.showAppWebpage
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
? WebView(
|
)
|
||||||
initialUrl: app?.app.url,
|
: Column(
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
app?.app.name ?? 'App',
|
||||||
children: [
|
textAlign: TextAlign.center,
|
||||||
Text(
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
app?.app.name ?? 'App',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.displayLarge,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'By ${app?.app.author ?? 'Unknown'}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: 32,
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if (app?.app.url != null) {
|
|
||||||
launchUrlString(app?.app.url ?? '',
|
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
app?.app.url ?? '',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
fontSize: 12),
|
|
||||||
)),
|
|
||||||
const SizedBox(
|
|
||||||
height: 32,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
]),
|
'By ${app?.app.author ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (app?.app.url != null) {
|
||||||
|
launchUrlString(app?.app.url ?? '',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
app?.app.url ?? '',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 12),
|
||||||
|
)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomSheet: Padding(
|
bottomSheet: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||||
@ -164,6 +163,38 @@ class _AppPageState extends State<AppPage> {
|
|||||||
},
|
},
|
||||||
tooltip: 'Mark as Not Installed',
|
tooltip: 'Mark as Not Installed',
|
||||||
icon: const Icon(Icons.no_cell_outlined)),
|
icon: const Icon(Icons.no_cell_outlined)),
|
||||||
|
if (source != null &&
|
||||||
|
source.additionalDataFormItems.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Additional Options',
|
||||||
|
items: source.additionalDataFormItems,
|
||||||
|
defaultValues: app != null
|
||||||
|
? app.app.additionalData
|
||||||
|
: source.additionalDataDefaults);
|
||||||
|
}).then((values) {
|
||||||
|
if (app != null && values != null) {
|
||||||
|
var changedApp = app.app;
|
||||||
|
changedApp.additionalData = values;
|
||||||
|
sourceProvider
|
||||||
|
.getApp(source, changedApp.url,
|
||||||
|
changedApp.additionalData)
|
||||||
|
.then((finalChangedApp) {
|
||||||
|
appsProvider.saveApp(finalChangedApp);
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.settings)),
|
||||||
const SizedBox(width: 16.0),
|
const SizedBox(width: 16.0),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -14,12 +16,47 @@ class AppsPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppsPageState extends State<AppsPage> {
|
class _AppsPageState extends State<AppsPage> {
|
||||||
|
AppsFilter? filter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.watch<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
||||||
var sortedApps = appsProvider.apps.values.toList();
|
var sortedApps = appsProvider.apps.values.toList();
|
||||||
|
|
||||||
|
if (filter != null) {
|
||||||
|
sortedApps = sortedApps.where((app) {
|
||||||
|
if (app.app.installedVersion == app.app.latestVersion &&
|
||||||
|
filter!.onlyNonLatest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<String> nameTokens = filter!.nameFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
List<String> authorTokens = filter!.authorFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (var t in nameTokens) {
|
||||||
|
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var t in authorTokens) {
|
||||||
|
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
sortedApps.sort((a, b) {
|
sortedApps.sort((a, b) {
|
||||||
int result = 0;
|
int result = 0;
|
||||||
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||||
@ -31,40 +68,103 @@ class _AppsPageState extends State<AppsPage> {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
||||||
sortedApps = sortedApps.reversed.toList();
|
sortedApps = sortedApps.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
floatingActionButton:
|
||||||
? null
|
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||||
: ElevatedButton.icon(
|
existingUpdateAppIds.isEmpty || filter != null
|
||||||
onPressed: appsProvider.areDownloadsRunning()
|
? const SizedBox()
|
||||||
? null
|
: ElevatedButton.icon(
|
||||||
: () {
|
onPressed: appsProvider.areDownloadsRunning()
|
||||||
HapticFeedback.heavyImpact();
|
? null
|
||||||
settingsProvider.getInstallPermission().then((_) {
|
: () {
|
||||||
appsProvider.downloadAndInstallLatestApp(
|
HapticFeedback.heavyImpact();
|
||||||
existingUpdateAppIds, context);
|
settingsProvider.getInstallPermission().then((_) {
|
||||||
|
appsProvider.downloadAndInstallLatestApp(
|
||||||
|
existingUpdateAppIds, context);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.install_mobile_outlined),
|
||||||
|
label: const Text('Install All')),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
appsProvider.apps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Filter Apps',
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: "App Name", required: false),
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: "Author", required: false)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: "Ignore Up-to-Date Apps",
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: filter == null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
filter!.nameFilter,
|
||||||
|
filter!.authorFilter,
|
||||||
|
filter!.onlyNonLatest ? 'true' : ''
|
||||||
|
]);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null &&
|
||||||
|
values
|
||||||
|
.where((element) => element.isNotEmpty)
|
||||||
|
.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
filter = AppsFilter(
|
||||||
|
nameFilter: values[0],
|
||||||
|
authorFilter: values[1],
|
||||||
|
onlyNonLatest: values[2] == "true");
|
||||||
});
|
});
|
||||||
},
|
} else {
|
||||||
icon: const Icon(Icons.install_mobile_outlined),
|
setState(() {
|
||||||
label: const Text('Install All')),
|
filter = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: Text(filter == null ? 'Search' : 'Modify Search'),
|
||||||
|
icon: Icon(
|
||||||
|
filter == null ? Icons.search : Icons.manage_search)),
|
||||||
|
]),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
return appsProvider.checkUpdates();
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: CustomScrollView(slivers: <Widget>[
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Apps'),
|
const CustomAppBar(title: 'Apps'),
|
||||||
if (appsProvider.loadingApps || appsProvider.apps.isEmpty)
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: appsProvider.loadingApps
|
child: appsProvider.loadingApps
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: Text(
|
: Text(
|
||||||
'No Apps',
|
appsProvider.apps.isEmpty
|
||||||
|
? 'No Apps'
|
||||||
|
: 'No Search Results',
|
||||||
style:
|
style:
|
||||||
Theme.of(context).textTheme.headlineMedium,
|
Theme.of(context).textTheme.headlineMedium,
|
||||||
))),
|
))),
|
||||||
@ -72,10 +172,8 @@ class _AppsPageState extends State<AppsPage> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(sortedApps[index].app.name),
|
||||||
'${sortedApps[index].app.author}/${sortedApps[index].app.name}'),
|
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||||
subtitle: Text(sortedApps[index].app.installedVersion ??
|
|
||||||
'Not Installed'),
|
|
||||||
trailing: sortedApps[index].downloadProgress != null
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
? Text(
|
? Text(
|
||||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||||
@ -83,7 +181,8 @@ class _AppsPageState extends State<AppsPage> {
|
|||||||
sortedApps[index].app.installedVersion !=
|
sortedApps[index].app.installedVersion !=
|
||||||
sortedApps[index].app.latestVersion
|
sortedApps[index].app.latestVersion
|
||||||
? const Text('Update Available')
|
? const Text('Update Available')
|
||||||
: null),
|
: Text(sortedApps[index].app.installedVersion ??
|
||||||
|
'Not Installed')),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@ -97,3 +196,14 @@ class _AppsPageState extends State<AppsPage> {
|
|||||||
])));
|
])));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsFilter {
|
||||||
|
late String nameFilter;
|
||||||
|
late String authorFilter;
|
||||||
|
late bool onlyNonLatest;
|
||||||
|
|
||||||
|
AppsFilter(
|
||||||
|
{this.nameFilter = "",
|
||||||
|
this.authorFilter = "",
|
||||||
|
this.onlyNonLatest = false});
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -54,74 +55,172 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomScrollView(slivers: <Widget>[
|
return Scaffold(
|
||||||
const CustomAppBar(title: 'Import/Export'),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
SliverFillRemaining(
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
hasScrollBody: false,
|
const CustomAppBar(title: 'Import/Export'),
|
||||||
child: Padding(
|
SliverFillRemaining(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
hasScrollBody: false,
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
padding:
|
||||||
children: [
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Row(
|
||||||
child: TextButton(
|
children: [
|
||||||
style: outlineButtonStyle,
|
Expanded(
|
||||||
onPressed: appsProvider.apps.isEmpty ||
|
child: TextButton(
|
||||||
importInProgress
|
style: outlineButtonStyle,
|
||||||
? null
|
onPressed: appsProvider.apps.isEmpty ||
|
||||||
: () {
|
importInProgress
|
||||||
HapticFeedback.selectionClick();
|
? null
|
||||||
appsProvider
|
: () {
|
||||||
.exportApps()
|
HapticFeedback.selectionClick();
|
||||||
.then((String path) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Exported to $path')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Obtainium Export'))),
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TextButton(
|
|
||||||
style: outlineButtonStyle,
|
|
||||||
onPressed: importInProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
FilePicker.platform
|
|
||||||
.pickFiles()
|
|
||||||
.then((result) {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = true;
|
|
||||||
});
|
|
||||||
if (result != null) {
|
|
||||||
String data =
|
|
||||||
File(result.files.single.path!)
|
|
||||||
.readAsStringSync();
|
|
||||||
try {
|
|
||||||
jsonDecode(data);
|
|
||||||
} catch (e) {
|
|
||||||
throw 'Invalid input';
|
|
||||||
}
|
|
||||||
appsProvider
|
appsProvider
|
||||||
.importApps(data)
|
.exportApps()
|
||||||
.then((value) {
|
.then((String path) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
.showSnackBar(
|
.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
'Exported to $path')),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Obtainium Export'))),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
FilePicker.platform
|
||||||
|
.pickFiles()
|
||||||
|
.then((result) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
if (result != null) {
|
||||||
|
String data = File(
|
||||||
|
result.files.single.path!)
|
||||||
|
.readAsStringSync();
|
||||||
|
try {
|
||||||
|
jsonDecode(data);
|
||||||
|
} catch (e) {
|
||||||
|
throw 'Invalid input';
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.importApps(data)
|
||||||
|
.then((value) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User canceled the picker
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Obtainium Import')))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (importInProgress)
|
||||||
|
Column(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
LinearProgressIndicator(),
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Import from URL List',
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'App URL List',
|
||||||
|
max: 7,
|
||||||
|
additionalValidators: [
|
||||||
|
(String? value) {
|
||||||
|
if (value != null &&
|
||||||
|
value.isNotEmpty) {
|
||||||
|
var lines = value
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
for (int i = 0;
|
||||||
|
i < lines.length;
|
||||||
|
i++) {
|
||||||
|
try {
|
||||||
|
sourceProvider
|
||||||
|
.getSource(
|
||||||
|
lines[i]);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Line ${i + 1}: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
var urls =
|
||||||
|
(values[0] as String).split('\n');
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
addApps(urls).then((errors) {
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${urls.length} Apps')),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// User canceled the picker
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls.length,
|
||||||
|
errors: errors);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
@ -133,148 +232,93 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
importInProgress = false;
|
importInProgress = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
|
||||||
child: const Text('Obtainium Import')))
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (importInProgress)
|
|
||||||
Column(
|
|
||||||
children: const [
|
|
||||||
SizedBox(
|
|
||||||
height: 14,
|
|
||||||
),
|
|
||||||
LinearProgressIndicator(),
|
|
||||||
SizedBox(
|
|
||||||
height: 14,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Divider(
|
|
||||||
height: 32,
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: importInProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return GeneratedFormModal(
|
|
||||||
title: 'Import from URL List',
|
|
||||||
items: [
|
|
||||||
GeneratedFormItem(
|
|
||||||
'App URL List', true, 7)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}).then((values) {
|
|
||||||
if (values != null) {
|
|
||||||
var urls = (values[0] as String).split('\n');
|
|
||||||
setState(() {
|
|
||||||
importInProgress = true;
|
|
||||||
});
|
|
||||||
addApps(urls).then((errors) {
|
|
||||||
if (errors.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Imported ${urls.length} Apps')),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return ImportErrorDialog(
|
|
||||||
urlsLength: urls.length,
|
|
||||||
errors: errors);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = false;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
});
|
child: const Text(
|
||||||
},
|
'Import from URL List',
|
||||||
child: const Text(
|
)),
|
||||||
'Import from URL List',
|
...sourceProvider.massSources
|
||||||
)),
|
.map((source) => Column(
|
||||||
...sourceProvider.massSources
|
crossAxisAlignment:
|
||||||
.map((source) => Column(
|
CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
TextButton(
|
||||||
TextButton(
|
onPressed: importInProgress
|
||||||
onPressed: importInProgress
|
? null
|
||||||
? null
|
: () {
|
||||||
: () {
|
showDialog(
|
||||||
showDialog(
|
context: context,
|
||||||
context: context,
|
builder:
|
||||||
builder: (BuildContext ctx) {
|
(BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title:
|
title:
|
||||||
'Import ${source.name}',
|
'Import ${source.name}',
|
||||||
items: source.requiredArgs
|
items: source
|
||||||
.map((e) =>
|
.requiredArgs
|
||||||
GeneratedFormItem(
|
.map((e) => [
|
||||||
e, true, 1))
|
GeneratedFormItem(
|
||||||
.toList());
|
label: e)
|
||||||
}).then((values) {
|
])
|
||||||
if (values != null) {
|
.toList(),
|
||||||
source
|
defaultValues: const [],
|
||||||
.getUrls(values)
|
);
|
||||||
.then((urls) {
|
}).then((values) {
|
||||||
setState(() {
|
if (values != null) {
|
||||||
importInProgress = true;
|
source
|
||||||
});
|
.getUrls(values)
|
||||||
addApps(urls).then((errors) {
|
.then((urls) {
|
||||||
if (errors.isEmpty) {
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
addApps(urls)
|
||||||
|
.then((errors) {
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${urls.length} Apps')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context)
|
context)
|
||||||
.showSnackBar(
|
.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Imported ${urls.length} Apps')),
|
e.toString())),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext
|
|
||||||
ctx) {
|
|
||||||
return ImportErrorDialog(
|
|
||||||
urlsLength:
|
|
||||||
urls.length,
|
|
||||||
errors: errors);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = false;
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content:
|
|
||||||
Text(e.toString())),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
});
|
child: Text('Import ${source.name}'))
|
||||||
},
|
]))
|
||||||
child: Text('Import ${source.name}'))
|
.toList()
|
||||||
]))
|
],
|
||||||
.toList()
|
)))
|
||||||
],
|
]));
|
||||||
)))
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -19,214 +18,219 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
}
|
}
|
||||||
return CustomScrollView(slivers: <Widget>[
|
return Scaffold(
|
||||||
const CustomAppBar(title: 'Add App'),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
SliverFillRemaining(
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
hasScrollBody: true,
|
const CustomAppBar(title: 'Settings'),
|
||||||
child: Padding(
|
SliverFillRemaining(
|
||||||
padding: const EdgeInsets.all(16),
|
hasScrollBody: true,
|
||||||
child: settingsProvider.prefs == null
|
child: Padding(
|
||||||
? Container()
|
padding: const EdgeInsets.all(16),
|
||||||
: Column(
|
child: settingsProvider.prefs == null
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? const SizedBox()
|
||||||
children: [
|
: Column(
|
||||||
Text(
|
|
||||||
'Appearance',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.primary),
|
|
||||||
),
|
|
||||||
DropdownButtonFormField(
|
|
||||||
decoration:
|
|
||||||
const InputDecoration(labelText: 'Theme'),
|
|
||||||
value: settingsProvider.theme,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.dark,
|
|
||||||
child: Text('Dark'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.light,
|
|
||||||
child: Text('Light'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.system,
|
|
||||||
child: Text('Follow System'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.theme = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
DropdownButtonFormField(
|
|
||||||
decoration:
|
|
||||||
const InputDecoration(labelText: 'Colour'),
|
|
||||||
value: settingsProvider.colour,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ColourSettings.basic,
|
|
||||||
child: Text('Obtainium'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ColourSettings.materialYou,
|
|
||||||
child: Text('Material You'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.colour = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: DropdownButtonFormField(
|
'Appearance',
|
||||||
decoration: const InputDecoration(
|
style: TextStyle(
|
||||||
labelText: 'App Sort By'),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
value: settingsProvider.sortColumn,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortColumnSettings.authorName,
|
|
||||||
child: Text('Author/Name'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortColumnSettings.nameAuthor,
|
|
||||||
child: Text('Name/Author'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortColumnSettings.added,
|
|
||||||
child: Text('As Added'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.sortColumn = value;
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
),
|
),
|
||||||
Expanded(
|
DropdownButtonFormField(
|
||||||
child: DropdownButtonFormField(
|
decoration:
|
||||||
decoration: const InputDecoration(
|
const InputDecoration(labelText: 'Theme'),
|
||||||
labelText: 'App Sort Order'),
|
value: settingsProvider.theme,
|
||||||
value: settingsProvider.sortOrder,
|
items: const [
|
||||||
items: const [
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value: ThemeSettings.dark,
|
||||||
value: SortOrderSettings.ascending,
|
child: Text('Dark'),
|
||||||
child: Text('Ascending'),
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value: ThemeSettings.light,
|
||||||
value: SortOrderSettings.descending,
|
child: Text('Light'),
|
||||||
child: Text('Descending'),
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
],
|
value: ThemeSettings.system,
|
||||||
onChanged: (value) {
|
child: Text('Follow System'),
|
||||||
if (value != null) {
|
)
|
||||||
settingsProvider.sortOrder = value;
|
],
|
||||||
}
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text('Show Source Webpage in App View'),
|
|
||||||
Switch(
|
|
||||||
value: settingsProvider.showAppWebpage,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
settingsProvider.showAppWebpage = value;
|
if (value != null) {
|
||||||
})
|
settingsProvider.theme = value;
|
||||||
],
|
}
|
||||||
),
|
|
||||||
const Divider(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'More',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.primary),
|
|
||||||
),
|
|
||||||
DropdownButtonFormField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText:
|
|
||||||
'Background Update Checking Interval'),
|
|
||||||
value: settingsProvider.updateInterval,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 15,
|
|
||||||
child: Text('15 Minutes'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 30,
|
|
||||||
child: Text('30 Minutes'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 60,
|
|
||||||
child: Text('1 Hour'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 360,
|
|
||||||
child: Text('6 Hours'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 720,
|
|
||||||
child: Text('12 Hours'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 1440,
|
|
||||||
child: Text('1 Day'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 0,
|
|
||||||
child: Text('Never - Manual Only'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.updateInterval = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
style: ButtonStyle(
|
|
||||||
foregroundColor:
|
|
||||||
MaterialStateProperty.resolveWith<Color>(
|
|
||||||
(Set<MaterialState> states) {
|
|
||||||
return Colors.grey;
|
|
||||||
}),
|
}),
|
||||||
),
|
const SizedBox(
|
||||||
onPressed: () {
|
height: 16,
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
),
|
||||||
mode: LaunchMode.externalApplication);
|
DropdownButtonFormField(
|
||||||
},
|
decoration:
|
||||||
icon: const Icon(Icons.code),
|
const InputDecoration(labelText: 'Colour'),
|
||||||
label: Text(
|
value: settingsProvider.colour,
|
||||||
'Source',
|
items: const [
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
DropdownMenuItem(
|
||||||
),
|
value: ColourSettings.basic,
|
||||||
)
|
child: Text('Obtainium'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ColourSettings.materialYou,
|
||||||
|
child: Text('Material You'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.colour = value;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'App Sort By'),
|
||||||
|
value: settingsProvider.sortColumn,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value:
|
||||||
|
SortColumnSettings.authorName,
|
||||||
|
child: Text('Author/Name'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value:
|
||||||
|
SortColumnSettings.nameAuthor,
|
||||||
|
child: Text('Name/Author'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortColumnSettings.added,
|
||||||
|
child: Text('As Added'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.sortColumn = value;
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'App Sort Order'),
|
||||||
|
value: settingsProvider.sortOrder,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortOrderSettings.ascending,
|
||||||
|
child: Text('Ascending'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortOrderSettings.descending,
|
||||||
|
child: Text('Descending'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.sortOrder = value;
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Show Source Webpage in App View'),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.showAppWebpage,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.showAppWebpage = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'More',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText:
|
||||||
|
'Background Update Checking Interval'),
|
||||||
|
value: settingsProvider.updateInterval,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 15,
|
||||||
|
child: Text('15 Minutes'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 30,
|
||||||
|
child: Text('30 Minutes'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 60,
|
||||||
|
child: Text('1 Hour'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 360,
|
||||||
|
child: Text('6 Hours'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 720,
|
||||||
|
child: Text('12 Hours'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 1440,
|
||||||
|
child: Text('1 Day'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 0,
|
||||||
|
child: Text('Never - Manual Only'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.updateInterval = value;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor:
|
||||||
|
MaterialStateProperty.resolveWith<
|
||||||
|
Color>((Set<MaterialState> states) {
|
||||||
|
return Colors.grey;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
label: Text(
|
||||||
|
'Source',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
)))
|
||||||
],
|
]));
|
||||||
)))
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
53
lib/pages/test_page.dart
Normal file
53
lib/pages/test_page.dart
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
|
||||||
|
class TestPage extends StatefulWidget {
|
||||||
|
const TestPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TestPage> createState() => _TestPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestPageState extends State<TestPage> {
|
||||||
|
List<String?>? sourceSpecificData;
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
List<List<GeneratedFormItem>> sourceSpecificInputs = [
|
||||||
|
[GeneratedFormItem(label: 'Test Item 1')],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(label: 'Test Item 2', required: false),
|
||||||
|
GeneratedFormItem(label: 'Test Item 3')
|
||||||
|
],
|
||||||
|
[GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)]
|
||||||
|
];
|
||||||
|
|
||||||
|
List<String> defaultInputValues = ["ABC"];
|
||||||
|
|
||||||
|
void onSourceSpecificDataChanges(
|
||||||
|
List<String?> valuesFromForm, bool formValid) {
|
||||||
|
setState(() {
|
||||||
|
sourceSpecificData = valuesFromForm;
|
||||||
|
valid = formValid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Test Page')),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(children: [
|
||||||
|
GeneratedForm(
|
||||||
|
items: sourceSpecificInputs,
|
||||||
|
onValueChanges: onSourceSpecificDataChanges,
|
||||||
|
defaultValues: defaultInputValues,
|
||||||
|
),
|
||||||
|
...(sourceSpecificData != null
|
||||||
|
? (sourceSpecificData as List<String?>)
|
||||||
|
.map((e) => Text(e ?? ""))
|
||||||
|
: [Container()])
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
@ -228,7 +228,11 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
Future<App?> getUpdate(String appId) async {
|
||||||
App? currentApp = apps[appId]!.app;
|
App? currentApp = apps[appId]!.app;
|
||||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
App newApp = await sourceProvider.getApp(
|
||||||
|
sourceProvider.getSource(currentApp.url),
|
||||||
|
currentApp.url,
|
||||||
|
currentApp.additionalData);
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
newApp.installedVersion = currentApp.installedVersion;
|
||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
|
@ -4,8 +4,14 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
import 'package:html/parser.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/app_sources/gitlab.dart';
|
||||||
|
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||||
|
import 'package:obtainium/app_sources/mullvad.dart';
|
||||||
|
import 'package:obtainium/app_sources/signal.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -30,8 +36,17 @@ class App {
|
|||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
late int preferredApkIndex;
|
late int preferredApkIndex;
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
late List<String> additionalData;
|
||||||
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
App(
|
||||||
|
this.id,
|
||||||
|
this.url,
|
||||||
|
this.author,
|
||||||
|
this.name,
|
||||||
|
this.installedVersion,
|
||||||
|
this.latestVersion,
|
||||||
|
this.apkUrls,
|
||||||
|
this.preferredApkIndex,
|
||||||
|
this.additionalData);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@ -39,19 +54,21 @@ class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
json['author'] as String,
|
json['author'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['installedVersion'] == null
|
json['installedVersion'] == null
|
||||||
? null
|
? null
|
||||||
: json['installedVersion'] as String,
|
: json['installedVersion'] as String,
|
||||||
json['latestVersion'] as String,
|
json['latestVersion'] as String,
|
||||||
List<String>.from(jsonDecode(json['apkUrls'])),
|
json['apkUrls'] == null
|
||||||
json['preferredApkIndex'] == null
|
? []
|
||||||
? 0
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
: json['preferredApkIndex'] as int,
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
);
|
json['additionalData'] == null
|
||||||
|
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||||
|
: List<String>.from(jsonDecode(json['additionalData'])));
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -61,7 +78,8 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
'preferredApkIndex': preferredApkIndex
|
'preferredApkIndex': preferredApkIndex,
|
||||||
|
'additionalData': jsonEncode(additionalData)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,10 +89,24 @@ escapeRegEx(String s) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const String couldNotFindReleases = 'Unable to fetch release info';
|
makeUrlHttps(String url) {
|
||||||
|
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||||
|
url.toLowerCase().indexOf('https://') != 0) {
|
||||||
|
url = 'https://$url';
|
||||||
|
}
|
||||||
|
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||||
|
url = 'https://${url.substring(12)}';
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String couldNotFindReleases = 'Could not find a suitable release';
|
||||||
const String couldNotFindLatestVersion =
|
const String couldNotFindLatestVersion =
|
||||||
'Could not determine latest release version';
|
'Could not determine latest release version';
|
||||||
const String notValidURL = 'Not a valid URL';
|
String notValidURL(String sourceName) {
|
||||||
|
return 'Not a valid $sourceName App URL';
|
||||||
|
}
|
||||||
|
|
||||||
const String noAPKFound = 'No APK found';
|
const String noAPKFound = 'No APK found';
|
||||||
|
|
||||||
List<String> getLinksFromParsedHTML(
|
List<String> getLinksFromParsedHTML(
|
||||||
@ -91,310 +123,21 @@ List<String> getLinksFromParsedHTML(
|
|||||||
abstract class AppSource {
|
abstract class AppSource {
|
||||||
late String host;
|
late String host;
|
||||||
String standardizeURL(String url);
|
String standardizeURL(String url);
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData);
|
||||||
AppNames getAppNames(String standardUrl);
|
AppNames getAppNames(String standardUrl);
|
||||||
|
late List<List<GeneratedFormItem>> additionalDataFormItems;
|
||||||
|
late List<String> additionalDataDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitHub implements AppSource {
|
abstract class MassAppSource {
|
||||||
@override
|
late String name;
|
||||||
late String host = 'github.com';
|
late List<String> requiredArgs;
|
||||||
|
Future<List<String>> getUrls(List<String> args);
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(
|
|
||||||
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
|
||||||
// Right now, the latest non-prerelease version is picked
|
|
||||||
// If none exists, the latest prerelease version is picked
|
|
||||||
// In the future, the user could be given a choice
|
|
||||||
var nonPrereleaseReleases =
|
|
||||||
releases.where((element) => element['prerelease'] != true).toList();
|
|
||||||
var latestRelease = nonPrereleaseReleases.isNotEmpty
|
|
||||||
? nonPrereleaseReleases[0]
|
|
||||||
: releases.isNotEmpty
|
|
||||||
? releases[0]
|
|
||||||
: null;
|
|
||||||
if (latestRelease == null) {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
List<dynamic>? assets = latestRelease['assets'];
|
|
||||||
List<String>? apkUrlList = assets
|
|
||||||
?.map((e) {
|
|
||||||
return e['browser_download_url'] != null
|
|
||||||
? e['browser_download_url'] as String
|
|
||||||
: '';
|
|
||||||
})
|
|
||||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
|
||||||
.toList();
|
|
||||||
if (apkUrlList == null || apkUrlList.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
String? version = latestRelease['tag_name'];
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
|
||||||
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
|
||||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
|
||||||
return AppNames(names[0], names[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitLab implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'gitlab.com';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var entry = parsedHtml.querySelector('entry');
|
|
||||||
var entryContent =
|
|
||||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
|
||||||
var apkUrlList = [
|
|
||||||
...getLinksFromParsedHTML(
|
|
||||||
entryContent,
|
|
||||||
RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
|
||||||
caseSensitive: false),
|
|
||||||
standardUri.origin),
|
|
||||||
// GitLab releases may contain links to externally hosted APKs
|
|
||||||
...getLinksFromParsedHTML(entryContent,
|
|
||||||
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
|
||||||
.where((element) => Uri.parse(element).host != '')
|
|
||||||
.toList()
|
|
||||||
];
|
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
|
||||||
var version =
|
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
// Same as GitHub
|
|
||||||
return GitHub().getAppNames(standardUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Signal implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'signal.org';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
return 'https://$host';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res =
|
|
||||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var json = jsonDecode(res.body);
|
|
||||||
String? apkUrl = json['url'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
String? version = json['versionName'];
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
|
||||||
}
|
|
||||||
|
|
||||||
class FDroid implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'f-droid.org';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var latestReleaseDiv =
|
|
||||||
parse(res.body).querySelector('#latest.package-version');
|
|
||||||
var apkUrl = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-download a')
|
|
||||||
?.attributes['href'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
var version = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-header b')
|
|
||||||
?.innerHtml
|
|
||||||
.split(' ')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Mullvad implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'mullvad.net';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var version = parse(res.body)
|
|
||||||
.querySelector('p.subtitle.is-6')
|
|
||||||
?.querySelector('a')
|
|
||||||
?.attributes['href']
|
|
||||||
?.split('/')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(
|
|
||||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IzzyOnDroid implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'android.izzysoft.de';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var multipleVersionApkUrls = parsedHtml
|
|
||||||
.querySelectorAll('a')
|
|
||||||
.where((element) =>
|
|
||||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
|
||||||
false)
|
|
||||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
|
||||||
.toList();
|
|
||||||
if (multipleVersionApkUrls.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
var version = parsedHtml
|
|
||||||
.querySelector('#keydata')
|
|
||||||
?.querySelectorAll('b')
|
|
||||||
.where(
|
|
||||||
(element) => element.innerHtml.toLowerCase().contains('version'))
|
|
||||||
.toList()[0]
|
|
||||||
.parentNode
|
|
||||||
?.parentNode
|
|
||||||
?.children[1]
|
|
||||||
.innerHtml;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
|
// Add more source classes here so they are available via the service
|
||||||
List<AppSource> sources = [
|
List<AppSource> sources = [
|
||||||
GitHub(),
|
GitHub(),
|
||||||
GitLab(),
|
GitLab(),
|
||||||
@ -404,10 +147,11 @@ class SourceProvider {
|
|||||||
Signal()
|
Signal()
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add more mass source classes here so they are available via the service
|
||||||
List<MassAppSource> massSources = [GitHubStars()];
|
List<MassAppSource> massSources = [GitHubStars()];
|
||||||
|
|
||||||
// Add more source classes here so they are available via the service
|
|
||||||
AppSource getSource(String url) {
|
AppSource getSource(String url) {
|
||||||
|
url = makeUrlHttps(url);
|
||||||
AppSource? source;
|
AppSource? source;
|
||||||
for (var s in sources) {
|
for (var s in sources) {
|
||||||
if (url.toLowerCase().contains('://${s.host}')) {
|
if (url.toLowerCase().contains('://${s.host}')) {
|
||||||
@ -421,18 +165,23 @@ class SourceProvider {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
for (var row in source.additionalDataFormItems) {
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
for (var element in row) {
|
||||||
url = 'https://$url';
|
if (element.required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
return false;
|
||||||
url = 'https://${url.substring(12)}';
|
}
|
||||||
}
|
|
||||||
AppSource source = getSource(url);
|
Future<App> getApp(
|
||||||
String standardUrl = source.standardizeURL(url);
|
AppSource source, String url, List<String> additionalData) async {
|
||||||
|
String standardUrl = source.standardizeURL(makeUrlHttps(url));
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
APKDetails apk =
|
||||||
|
await source.getLatestAPKDetails(standardUrl, additionalData);
|
||||||
return App(
|
return App(
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||||
standardUrl,
|
standardUrl,
|
||||||
@ -441,7 +190,8 @@ class SourceProvider {
|
|||||||
null,
|
null,
|
||||||
apk.version,
|
apk.version,
|
||||||
apk.apkUrls,
|
apk.apkUrls,
|
||||||
apk.apkUrls.length - 1);
|
apk.apkUrls.length - 1,
|
||||||
|
additionalData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a length 2 list, where the first element is a list of Apps and
|
/// Returns a length 2 list, where the first element is a list of Apps and
|
||||||
@ -451,7 +201,8 @@ class SourceProvider {
|
|||||||
Map<String, dynamic> errors = {};
|
Map<String, dynamic> errors = {};
|
||||||
for (var url in urls) {
|
for (var url in urls) {
|
||||||
try {
|
try {
|
||||||
apps.add(await getApp(url));
|
var source = getSource(url);
|
||||||
|
apps.add(await getApp(source, url, source.additionalDataDefaults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.addAll(<String, dynamic>{url: e});
|
errors.addAll(<String, dynamic>{url: e});
|
||||||
}
|
}
|
||||||
@ -461,37 +212,3 @@ class SourceProvider {
|
|||||||
|
|
||||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MassAppSource {
|
|
||||||
late String name;
|
|
||||||
late List<String> requiredArgs;
|
|
||||||
Future<List<String>> getUrls(List<String> args);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHubStars implements MassAppSource {
|
|
||||||
@override
|
|
||||||
late String name = 'GitHub Starred Repos';
|
|
||||||
|
|
||||||
@override
|
|
||||||
late List<String> requiredArgs = ['Username'];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> getUrls(List<String> args) async {
|
|
||||||
if (args.length != requiredArgs.length) {
|
|
||||||
throw 'Wrong number of arguments provided';
|
|
||||||
}
|
|
||||||
Response res =
|
|
||||||
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
return (jsonDecode(res.body) as List<dynamic>)
|
|
||||||
.map((e) => e['html_url'] as String)
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
|
||||||
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw 'Unable to find user\'s starred repos';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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.2.2+13 # When changing this, update the tag in main() accordingly
|
version: 0.3.2+18 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||||
|
Reference in New Issue
Block a user