Adds Source-specific options + other changes (#26)

* Started work on dynamic forms

* dynamic form progress (switch doesn't work)

* dynamic forms work

* Gen. form improvements, source specific data (untested)

* Gen form bugfix

* Removed redundant generated modal code

* Added custom validators to gen. forms

* Progress on source options (incomplete), gen form bugfixes

* Tweaks, more

* More

* Progress

* Changed a default

* Additional options done!
This commit is contained in:
Imran Remtulla
2022-09-24 00:36:32 -04:00
committed by GitHub
parent 224e435bbb
commit d03486fc5d
16 changed files with 678 additions and 230 deletions

View File

@@ -1,5 +1,6 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource { class FDroid implements AppSource {
@@ -11,13 +12,14 @@ class FDroid implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL; throw notValidURL(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl)); Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var latestReleaseDiv = var latestReleaseDiv =
@@ -46,4 +48,10 @@ class FDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitHub implements AppSource { class GitHub implements AppSource {
@@ -11,47 +12,68 @@ class GitHub implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL; throw notValidURL(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { 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( Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); 'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>; 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 List<String> getReleaseAPKUrls(dynamic release) =>
// In the future, the user could be given a choice (release['assets'] as List<dynamic>?)
var nonPrereleaseReleases = ?.map((e) {
releases.where((element) => element['prerelease'] != true).toList(); return e['browser_download_url'] != null
var latestRelease = nonPrereleaseReleases.isNotEmpty ? e['browser_download_url'] as String
? nonPrereleaseReleases[0] : '';
: releases.isNotEmpty })
? releases[0] .where((element) => element.toLowerCase().endsWith('.apk'))
: null; .toList() ??
if (latestRelease == null) { [];
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; throw couldNotFindReleases;
} }
List<dynamic>? assets = latestRelease['assets']; if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
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; throw noAPKFound;
} }
String? version = latestRelease['tag_name']; String? version = targetRelease['tag_name'];
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw couldNotFindLatestVersion;
} }
return APKDetails(version, apkUrlList); return APKDetails(version, targetRelease['apkUrls']);
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { 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 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
@@ -67,4 +89,34 @@ class GitHub implements AppSource {
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]); 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";
}
}
])
]
];
@override
List<String> additionalDataDefaults = ["true", "true", ""];
} }

View File

@@ -1,6 +1,7 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource { class GitLab implements AppSource {
@@ -12,13 +13,14 @@ class GitLab implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL; throw notValidURL(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl); var standardUri = Uri.parse(standardUrl);
@@ -60,4 +62,10 @@ class GitLab implements AppSource {
// Same as GitHub // Same as GitHub
return GitHub().getAppNames(standardUrl); return GitHub().getAppNames(standardUrl);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
} }

View File

@@ -1,5 +1,6 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource { class IzzyOnDroid implements AppSource {
@@ -11,13 +12,14 @@ class IzzyOnDroid implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL; throw notValidURL(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl)); Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
@@ -54,4 +56,10 @@ class IzzyOnDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
} }

View File

@@ -1,5 +1,6 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource { class Mullvad implements AppSource {
@@ -11,13 +12,14 @@ class Mullvad implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL; throw notValidURL(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android')); Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var version = parse(res.body) var version = parse(res.body)
@@ -40,4 +42,10 @@ class Mullvad implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN'); return AppNames('Mullvad-VPN', 'Mullvad-VPN');
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource { class Signal implements AppSource {
@@ -12,7 +13,8 @@ class Signal implements AppSource {
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = Response res =
await get(Uri.parse('https://updates.$host/android/latest.json')); await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
@@ -33,4 +35,10 @@ class Signal implements AppSource {
@override @override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
} }

View 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)],
))
],
));
}
}

View File

@@ -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

View File

@@ -59,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())
@@ -103,7 +103,8 @@ class MyApp extends StatelessWidget {
currentReleaseTag, currentReleaseTag,
currentReleaseTag, currentReleaseTag,
[], [],
0)); 0,
["true"]));
} }
} }

View File

@@ -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,10 +17,13 @@ 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();
@@ -28,103 +32,147 @@ class _AddAppPageState extends State<AddAppPage> {
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Add App'), const CustomAppBar(title: 'Add App'),
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, child: Padding(
child: Center( padding: const EdgeInsets.all(16),
child: Form( child: Column(
key: _formKey, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, Row(
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Expanded(
Container(), child: GeneratedForm(
Padding( items: [
padding: const EdgeInsets.all(16), [
child: Column( GeneratedFormItem(
crossAxisAlignment: CrossAxisAlignment.stretch, label: "App Source Url",
children: [ additionalValidators: [
TextFormField( (value) {
decoration: const InputDecoration( try {
hintText: sourceProvider
'https://github.com/Author/Project', .getSource(value ?? "")
helperText: 'Enter the App source URL'), .standardizeURL(
controller: urlInputController, makeUrlHttps(
validator: (value) { value ?? ""));
if (value == null || } catch (e) {
value.isEmpty || return e is String
Uri.tryParse(value) == null) { ? e
return 'Please enter a supported source URL'; : "Error";
}
return null;
},
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.selectionClick();
if (_formKey.currentState!
.validate()) {
setState(() {
gettingAppInfo = true;
});
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 return null;
.getInstallPermission() }
.then((_) { ])
appsProvider ]
.saveApp(app) ],
.then((_) { onValueChanges: (values, valid) {
urlInputController.clear(); setState(() {
Navigator.push( userInput = values[0];
context, var source = valid
MaterialPageRoute( ? sourceProvider.getSource(userInput)
builder: (context) => : null;
AppPage( if (pickedSource != source) {
appId: pickedSource = source;
app.id))); additionalData = [];
}); validAdditionalData = source != null
}); ? sourceProvider
}).catchError((e) { .doesSourceHaveRequiredAdditionalData(
ScaffoldMessenger.of(context) source)
.showSnackBar( : true;
SnackBar( }
content: });
Text(e.toString())), },
); defaultValues: const [])),
}).whenComplete(() { const SizedBox(
setState(() { width: 16,
gettingAppInfo = false;
});
});
}
},
child: const Text('Add'),
),
),
],
), ),
), ElevatedButton(
onPressed: gettingAppInfo ||
pickedSource == null ||
(pickedSource!.additionalDataFormItems
.isNotEmpty &&
!validAdditionalData)
? null
: () {
HapticFeedback.selectionClick();
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(() {
gettingAppInfo = false;
});
});
},
child: const Text('Add'))
],
),
const Divider(
height: 64,
),
if (pickedSource != null &&
(pickedSource!.additionalDataFormItems.isNotEmpty))
Column( Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(
'Additional Options for ${pickedSource?.runtimeType}',
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
GeneratedForm(
items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) {
setState(() {
additionalData = values;
validAdditionalData = valid;
});
},
defaultValues:
pickedSource!.additionalDataDefaults)
],
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// const SizedBox(
// height: 48,
// ),
const Text( const Text(
'Supported Sources:', 'Supported Sources:',
// style: TextStyle(fontWeight: FontWeight.bold),
// style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox( const SizedBox(
height: 8, height: 8,
@@ -145,14 +193,9 @@ class _AddAppPageState extends State<AddAppPage> {
fontStyle: FontStyle.italic), fontStyle: FontStyle.italic),
))) )))
.toList() .toList()
]), ])),
if (gettingAppInfo) ])),
const LinearProgressIndicator() )
else
Container(),
],
)),
))
])); ]));
} }
} }

View File

@@ -1,7 +1,10 @@
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';
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';
@@ -20,7 +23,9 @@ 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);
} }
@@ -159,6 +164,37 @@ 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:
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(

View File

@@ -5,6 +5,7 @@ import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart'; import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/pages/settings.dart';
import 'package:obtainium/pages/test_page.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});

View File

@@ -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';
@@ -167,9 +168,34 @@ class _ImportExportPageState extends State<ImportExportPage> {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Import from URL List', title: 'Import from URL List',
items: [ items: [
GeneratedFormItem( [
'App URL List', true, 7) 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';
}
}
}
}
])
]
], ],
defaultValues: const [],
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
@@ -226,16 +252,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
builder: builder:
(BuildContext ctx) { (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Import ${source.name}', 'Import ${source.name}',
items: source items: source
.requiredArgs .requiredArgs
.map((e) => .map((e) => [
GeneratedFormItem( GeneratedFormItem(
e, label: e)
true, ])
1)) .toList(),
.toList()); defaultValues: const [],
);
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
source source

53
lib/pages/test_page.dart Normal file
View 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()])
])));
}
}

View File

@@ -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) {

View File

@@ -10,6 +10,7 @@ import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart'; import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames { class AppNames {
@@ -35,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() {
@@ -44,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,
@@ -66,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)
}; };
} }
@@ -76,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(
@@ -96,8 +123,12 @@ 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; // TODO: Make these integrate into generated form
} }
abstract class MassAppSource { abstract class MassAppSource {
@@ -121,6 +152,7 @@ class SourceProvider {
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppSource> massSources = [GitHubStars()];
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}')) {
@@ -134,18 +166,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,
@@ -154,7 +191,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
@@ -164,7 +202,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});
} }