mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 21:36:42 +02:00
Compare commits
52 Commits
v0.9.8-bet
...
v0.10.2-be
Author | SHA1 | Date | |
---|---|---|---|
b165400a6e | |||
c47bf937f1 | |||
2e19a8c04c | |||
05d4da86ec | |||
e9d1b04d54 | |||
cff5334c25 | |||
a55346fc22 | |||
885df678e5 | |||
bf7b0c5702 | |||
2972da4609 | |||
b8567af98e | |||
ea62c68b40 | |||
08a5af0449 | |||
36f327c16e | |||
768213cb34 | |||
e888fb7120 | |||
1fb68dd674 | |||
5c4bb8f84c | |||
1c8e759494 | |||
081c2a07d2 | |||
02751fe8fa | |||
95f3362a84 | |||
b68cf5a1be | |||
4eb7499591 | |||
98fafe2aa4 | |||
9bac74aadd | |||
0a93117bf0 | |||
451cc41c45 | |||
3b449d0982 | |||
1863f55372 | |||
0c4b8ac79d | |||
e287087753 | |||
82bcc46d42 | |||
1f26188ec6 | |||
794c3e1a81 | |||
16369b4adf | |||
8f16f745be | |||
8ddeb3d776 | |||
21cf9c98d9 | |||
358f910d19 | |||
7a3d74bd05 | |||
6f27f64699 | |||
3341fecb68 | |||
d3bce63ca4 | |||
8aa8b6b698 | |||
3d6c9bbf98 | |||
7af0a8628c | |||
4573ce6bcf | |||
e29d38fa32 | |||
dc82431235 | |||
424b0028bf | |||
46fba9e0a4 |
@ -1,4 +1,4 @@
|
||||
#  Obtainium
|
||||
#  Obtainium
|
||||
|
||||
Get Android App Updates Directly From the Source.
|
||||
|
||||
@ -9,6 +9,7 @@ Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to
|
||||
Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [Codeberg](https://codeberg.org/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
@ -18,6 +19,8 @@ Currently supported App sources:
|
||||
- Third Party F-Droid Repos
|
||||
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
|
||||
- [Steam](https://store.steampowered.com/mobile)
|
||||
- "HTML" (Fallback)
|
||||
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)
|
||||
|
||||
## Limitations
|
||||
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
|
||||
|
@ -51,4 +51,7 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"/>
|
||||
</manifest>
|
Binary file not shown.
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.1 KiB |
BIN
assets/graphics/icon_small.png
Normal file
BIN
assets/graphics/icon_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.6 KiB |
@ -209,6 +209,8 @@
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
|
||||
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"
|
||||
|
@ -209,6 +209,8 @@
|
||||
"addCategory": "Add Category",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Too many requests (rate limited) - try again in {} minute",
|
||||
"other": "Too many requests (rate limited) - try again in {} minutes"
|
||||
|
@ -59,7 +59,7 @@
|
||||
"byX": "{} által",
|
||||
"percentProgress": "Folyamat: {}%",
|
||||
"pleaseWait": "Kis türelmet",
|
||||
"updateAvailable": "Frissítés elérhető",
|
||||
"updateAvailable": "Frissítés érhető el",
|
||||
"estimateInBracketsShort": "(Becsült)",
|
||||
"notInstalled": "Nem telepített",
|
||||
"estimateInBrackets": "(Becslés)",
|
||||
@ -70,11 +70,11 @@
|
||||
"removeSelectedApps": "Távolítsa el a kiválasztott appokat",
|
||||
"updateX": "Frissítés: {}",
|
||||
"installX": "Telepítés: {}",
|
||||
"markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nas Frissítve",
|
||||
"markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nmint Frissített",
|
||||
"changeX": "Változás {}",
|
||||
"installUpdateApps": "Appok telepítése/frissítése",
|
||||
"installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat",
|
||||
"onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető automatikusan (nem gyakori).",
|
||||
"onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető autom. (nem gyakori).",
|
||||
"markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?",
|
||||
"no": "Nem",
|
||||
"yes": "Igen",
|
||||
@ -86,8 +86,8 @@
|
||||
"shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit",
|
||||
"resetInstallStatus": "Telepítési állapot visszaállítása",
|
||||
"more": "További",
|
||||
"removeOutdatedFilter": "Távolítsa el az elavult alkalmazásszűrőt",
|
||||
"showOutdatedOnly": "Csak az elavult alkalmazások megjelenítése",
|
||||
"removeOutdatedFilter": "Távolítsa el az elavult app szűrőt",
|
||||
"showOutdatedOnly": "Csak az elavult appok megjelenítése",
|
||||
"filter": "Szűrő",
|
||||
"filterActive": "Szűrő *",
|
||||
"filterApps": "Appok szűrése",
|
||||
@ -126,11 +126,11 @@
|
||||
"appSortBy": "App rendezés...",
|
||||
"authorName": "Szerző/Név",
|
||||
"nameAuthor": "Név/Szerző",
|
||||
"asAdded": "Mint hozzáadott",
|
||||
"asAdded": "Mint Hozzáadott",
|
||||
"appSortOrder": "Appok rendezése",
|
||||
"ascending": "Emelkedő",
|
||||
"descending": "Csökkenő",
|
||||
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzési időköz",
|
||||
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzés időköze",
|
||||
"neverManualOnly": "Soha – csak manuális",
|
||||
"appearance": "Megjelenés",
|
||||
"showWebInAppView": "Forrás megjelenítése az Appok nézetben",
|
||||
@ -155,14 +155,14 @@
|
||||
"noNewUpdates": "Nincsenek új frissítések.",
|
||||
"xHasAnUpdate": "A(z) {} frissítést kapott.",
|
||||
"appsUpdated": "Alkalmazások frissítve",
|
||||
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy vagy több app frissítése történt a háttérben",
|
||||
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy/több app frissítése megtörtént a háttérben",
|
||||
"xWasUpdatedToY": "{} frissítve a következőre: {}.",
|
||||
"errorCheckingUpdates": "Hiba a frissítések keresésekor",
|
||||
"errorCheckingUpdatesNotifDescription": "Értesítés, amely akkor jelenik meg, ha a háttérbeli frissítések ellenőrzése sikertelen",
|
||||
"appsRemoved": "Alkalmazások eltávolítva",
|
||||
"appsRemovedNotifDescription": "Értesíti a felhasználót egy vagy több alkalmazás eltávolításáról a betöltésük során fellépő hibák miatt",
|
||||
"xWasRemovedDueToErrorY": "A(z) {} a következő hiba miatt lett eltávolítva: {}",
|
||||
"completeAppInstallation": "Teljes alkalmazástelepítés",
|
||||
"completeAppInstallation": "Teljes app telepítés",
|
||||
"obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez",
|
||||
"completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését",
|
||||
"checkingForUpdates": "Frissítések keresése",
|
||||
@ -207,6 +207,9 @@
|
||||
"categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.",
|
||||
"addCategory": "Új kategória",
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
"storagePermissionDenied": "Tárhely engedély megtagadva",
|
||||
"selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva",
|
||||
"other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva"
|
||||
|
@ -209,6 +209,8 @@
|
||||
"addCategory": "Aggiungi categoria",
|
||||
"label": "Etichetta",
|
||||
"language": "Lingua",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
|
||||
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
|
||||
|
@ -209,6 +209,8 @@
|
||||
"addCategory": "カテゴリを追加",
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"storagePermissionDenied": "ストレージ権限が拒否されました",
|
||||
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
|
||||
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
|
||||
|
@ -12,7 +12,7 @@
|
||||
"ok": "好的",
|
||||
"and": "和",
|
||||
"startedBgUpdateTask": "开始后台检查更新任务",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"bgUpdateIgnoreAfterIs": "下次后台更新检查 {}",
|
||||
"startedActualBGUpdateCheck": "后台检查更新已开始",
|
||||
"bgUpdateTaskFinished": "后台检查更新已完成",
|
||||
"firstRun": "这是你第一次运行 Obtainium",
|
||||
@ -199,16 +199,18 @@
|
||||
"downloadNotifDescription": "通知用户下载进度",
|
||||
"noAPKFound": "未找到安装包",
|
||||
"noVersionDetection": "无版本检测",
|
||||
"categorize": "Categorize",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"noCategory": "No Category",
|
||||
"noCategories": "No Categories",
|
||||
"deleteCategoriesQuestion": "Delete Categories?",
|
||||
"categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.",
|
||||
"addCategory": "Add Category",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"categorize": "归档",
|
||||
"categories": "归档",
|
||||
"category": "类别",
|
||||
"noCategory": "无类别",
|
||||
"noCategories": "无类别",
|
||||
"deleteCategoriesQuestion": "删除所有类别?",
|
||||
"categoryDeleteWarning": "所有被删除类别的应用程序将被设置为无类别",
|
||||
"addCategory": "添加类别",
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"storagePermissionDenied": "存储权限已被拒绝",
|
||||
"selectedCategorizeWarning": "这将取代所选应用程序的任何现有类别",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
|
||||
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
|
||||
|
157
lib/app_sources/codeberg.dart
Normal file
157
lib/app_sources/codeberg.dart
Normal file
@ -0,0 +1,157 @@
|
||||
import 'dart:convert';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Codeberg extends AppSource {
|
||||
Codeberg() {
|
||||
host = 'codeberg.org';
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('includePrereleases',
|
||||
label: tr('includePrereleases'), defaultValue: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('filterReleaseTitlesByRegEx',
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool includePrereleases = additionalSettings['includePrereleases'];
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'];
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
true
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://$host/api/v1/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['name'] != null && e['browser_download_url'] != null
|
||||
? MapEntry(e['name'] as String,
|
||||
e['browser_download_url'] as String)
|
||||
: const MapEntry('', '');
|
||||
})
|
||||
.where((element) => element.key.toLowerCase().endsWith('.apk'))
|
||||
.map((e) => e.value)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
dynamic targetRelease;
|
||||
|
||||
for (int i = 0; i < releases.length; i++) {
|
||||
if (!fallbackToOlderReleases && i > 0) break;
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
if (releases[i]['draft'] == true) {
|
||||
// Draft releases not supported
|
||||
}
|
||||
var nameToFilter = releases[i]['name'] as String;
|
||||
if (nameToFilter.trim().isEmpty) {
|
||||
// Some leave titles empty so tag is used
|
||||
nameToFilter = releases[i]['tag_name'] as String;
|
||||
}
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
targetRelease['apkUrls'] = apkUrls;
|
||||
break;
|
||||
}
|
||||
if (targetRelease == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
|
||||
getAppNames(standardUrl));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Future<Map<String, String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100'));
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)['data'] as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ class GitHub extends AppSource {
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormTextField('github-creds',
|
||||
label: tr('githubPATLabel'),
|
||||
password: true,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
@ -140,10 +141,13 @@ class GitHub extends AppSource {
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameToFilter = releases[i]['name'] as String;
|
||||
if (nameToFilter.trim().isEmpty) {
|
||||
// Some leave titles empty so tag is used
|
||||
nameToFilter = releases[i]['tag_name'] as String;
|
||||
}
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
|
47
lib/app_sources/html.dart
Normal file
47
lib/app_sources/html.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class HTML extends AppSource {
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
var uri = Uri.parse(standardUrl);
|
||||
Response res = await get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
List<String> links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((element) => element.attributes['href'] ?? '')
|
||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
||||
if (links.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var rel = links.last;
|
||||
var apkName = rel.split('/').last;
|
||||
var version = apkName.substring(0, apkName.length - 4);
|
||||
List<String> apkUrls = [rel]
|
||||
.map((e) => e.toLowerCase().startsWith('http://') ||
|
||||
e.toLowerCase().startsWith('https://')
|
||||
? e
|
||||
: '${uri.origin}/$e')
|
||||
.toList();
|
||||
return APKDetails(version, apkUrls, AppNames(uri.host, tr('app')));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ import 'dart:math';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
|
||||
abstract class GeneratedFormItem {
|
||||
late String key;
|
||||
@ -24,6 +23,7 @@ class GeneratedFormTextField extends GeneratedFormItem {
|
||||
late bool required;
|
||||
late int max;
|
||||
late String? hint;
|
||||
late bool password;
|
||||
|
||||
GeneratedFormTextField(String key,
|
||||
{String label = 'Input',
|
||||
@ -32,7 +32,8 @@ class GeneratedFormTextField extends GeneratedFormItem {
|
||||
List<String? Function(String? value)> additionalValidators = const [],
|
||||
this.required = true,
|
||||
this.max = 1,
|
||||
this.hint})
|
||||
this.hint,
|
||||
this.password = false})
|
||||
: super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
@ -91,6 +92,7 @@ class GeneratedFormTagInput extends GeneratedFormItem {
|
||||
late bool singleSelect;
|
||||
late WrapAlignment alignment;
|
||||
late String emptyMessage;
|
||||
late bool showLabelWhenNotEmpty;
|
||||
GeneratedFormTagInput(String key,
|
||||
{String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
@ -100,7 +102,8 @@ class GeneratedFormTagInput extends GeneratedFormItem {
|
||||
this.deleteConfirmationMessage,
|
||||
this.singleSelect = false,
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.emptyMessage = 'Input'})
|
||||
this.emptyMessage = 'Input',
|
||||
this.showLabelWhenNotEmpty = true})
|
||||
: super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
@ -127,30 +130,6 @@ class GeneratedForm extends StatefulWidget {
|
||||
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||
}
|
||||
|
||||
class _GeneratedFormState extends State<GeneratedForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
Map<String, dynamic> 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({bool isBuilding = false}) {
|
||||
Map<String, dynamic> returnValues = values;
|
||||
var valid = true;
|
||||
for (int r = 0; r < widget.items.length; r++) {
|
||||
for (int i = 0; i < widget.items[r].length; i++) {
|
||||
if (formInputs[r][i] is TextFormField) {
|
||||
valid = valid &&
|
||||
((formInputs[r][i].key as GlobalKey<FormFieldState>)
|
||||
.currentState
|
||||
?.isValid ??
|
||||
false);
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
// Generates a random light color
|
||||
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
|
||||
Color generateRandomLightColor() {
|
||||
@ -166,6 +145,30 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
|
||||
}
|
||||
|
||||
class _GeneratedFormState extends State<GeneratedForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
Map<String, dynamic> 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({bool isBuilding = false}) {
|
||||
Map<String, dynamic> returnValues = values;
|
||||
var valid = true;
|
||||
for (int r = 0; r < widget.items.length; r++) {
|
||||
for (int i = 0; i < widget.items[r].length; i++) {
|
||||
if (formInputs[r][i] is TextFormField) {
|
||||
var fieldState =
|
||||
(formInputs[r][i].key as GlobalKey<FormFieldState>).currentState;
|
||||
if (fieldState != null) {
|
||||
valid = valid && fieldState.isValid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -186,6 +189,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
if (formItem is GeneratedFormTextField) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
obscureText: formItem.password,
|
||||
autocorrect: !formItem.password,
|
||||
enableSuggestions: !formItem.password,
|
||||
key: formFieldKey,
|
||||
initialValue: values[formItem.key],
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
@ -259,8 +265,30 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
],
|
||||
);
|
||||
} else if (widget.items[r][e] is GeneratedFormTagInput) {
|
||||
formInputs[r][e] = Wrap(
|
||||
alignment: (widget.items[r][e] as GeneratedFormTagInput).alignment,
|
||||
formInputs[r][e] =
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
if ((values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?)
|
||||
?.isNotEmpty ==
|
||||
true &&
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
.showLabelWhenNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
|
||||
WrapAlignment.center
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(widget.items[r][e].label),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
alignment:
|
||||
(widget.items[r][e] as GeneratedFormTagInput).alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
(values[widget.items[r][e].key]
|
||||
@ -270,7 +298,6 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
? Text(
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
.emptyMessage,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
...(values[widget.items[r][e].key]
|
||||
@ -295,20 +322,24 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
MapEntry<int, bool>>)[e2.key]!
|
||||
.key,
|
||||
value);
|
||||
if ((widget.items[r][e] as GeneratedFormTagInput)
|
||||
if ((widget.items[r][e]
|
||||
as GeneratedFormTagInput)
|
||||
.singleSelect &&
|
||||
value == true) {
|
||||
for (var key in (values[widget.items[r][e].key]
|
||||
for (var key in (values[
|
||||
widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>)
|
||||
.keys) {
|
||||
if (key != e2.key) {
|
||||
(values[widget.items[r][e].key] as Map<
|
||||
String,
|
||||
MapEntry<int,
|
||||
bool>>)[key] = MapEntry(
|
||||
(values[widget.items[r][e].key] as Map<
|
||||
MapEntry<int, bool>>)[key] =
|
||||
MapEntry(
|
||||
(values[widget.items[r][e].key]
|
||||
as Map<
|
||||
String,
|
||||
MapEntry<int, bool>>)[key]!
|
||||
MapEntry<int,
|
||||
bool>>)[key]!
|
||||
.key,
|
||||
false);
|
||||
}
|
||||
@ -389,8 +420,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
var temp = values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?;
|
||||
temp ??= {};
|
||||
var singleSelect =
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
if (temp[label] == null) {
|
||||
var singleSelect = (widget.items[r][e]
|
||||
as GeneratedFormTagInput)
|
||||
.singleSelect;
|
||||
var someSelected = temp.entries
|
||||
.where((element) => element.value.value)
|
||||
@ -400,6 +432,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
!(someSelected && singleSelect));
|
||||
values[widget.items[r][e].key] = temp;
|
||||
someValueChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -409,7 +442,8 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
tooltip: tr('add'),
|
||||
)),
|
||||
],
|
||||
);
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,12 +9,16 @@ class GeneratedFormModal extends StatefulWidget {
|
||||
required this.title,
|
||||
required this.items,
|
||||
this.initValid = false,
|
||||
this.message = ''});
|
||||
this.message = '',
|
||||
this.additionalWidgets = const [],
|
||||
this.singleNullReturnButton});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final List<List<GeneratedFormItem>> items;
|
||||
final bool initValid;
|
||||
final List<Widget> additionalWidgets;
|
||||
final String? singleNullReturnButton;
|
||||
|
||||
@override
|
||||
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||
@ -54,15 +58,19 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
this.valid = valid;
|
||||
});
|
||||
}
|
||||
})
|
||||
}),
|
||||
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
child: Text(widget.singleNullReturnButton == null
|
||||
? tr('cancel')
|
||||
: widget.singleNullReturnButton!)),
|
||||
widget.singleNullReturnButton == null
|
||||
? TextButton(
|
||||
onPressed: !valid
|
||||
? null
|
||||
: () {
|
||||
@ -72,6 +80,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
}
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
: const SizedBox.shrink()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -13,13 +13,10 @@ class ObtainiumError {
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError {
|
||||
class RateLimitError extends ObtainiumError {
|
||||
late int remainingMinutes;
|
||||
RateLimitError(this.remainingMinutes);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
|
||||
RateLimitError(this.remainingMinutes)
|
||||
: super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes));
|
||||
}
|
||||
|
||||
class InvalidURLError extends ObtainiumError {
|
||||
|
@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
|
||||
const String currentVersion = '0.9.8';
|
||||
const String currentVersion = '0.10.2';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
|
@ -8,6 +8,7 @@ import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -23,21 +24,26 @@ class AddAppPage extends StatefulWidget {
|
||||
|
||||
class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
bool searching = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
AppSource? pickedSource;
|
||||
Map<String, dynamic> additionalSettings = {};
|
||||
bool additionalSettingsValid = true;
|
||||
List<String> pickedCategories = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
bool doingSomething = gettingAppInfo || searching;
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource.runtimeType != source.runtimeType) {
|
||||
pickedSource = source;
|
||||
@ -49,13 +55,6 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
|
||||
: true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
fn();
|
||||
} else {
|
||||
setState(() {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -131,6 +130,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
if (app.additionalSettings['trackOnly'] == true) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
app.categories = pickedCategories;
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
@ -201,7 +201,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
onPressed: doingSomething ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!
|
||||
.combinedAppSpecificSettingFormItems
|
||||
@ -238,7 +238,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid) {
|
||||
if (values.isNotEmpty &&
|
||||
valid &&
|
||||
!isBuilding) {
|
||||
setState(() {
|
||||
searchQuery =
|
||||
values['searchSomeSources']!.trim();
|
||||
@ -250,9 +252,12 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
onPressed: searchQuery.isEmpty || doingSomething
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
searching = true;
|
||||
});
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
@ -289,19 +294,21 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
if (selectedUrls != null &&
|
||||
selectedUrls.isNotEmpty) {
|
||||
changeUserInput(
|
||||
selectedUrls[0], true, true);
|
||||
selectedUrls[0], true, false);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
searching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null &&
|
||||
(pickedSource!
|
||||
.combinedAppSpecificSettingFormItems.isNotEmpty))
|
||||
if (pickedSource != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@ -328,6 +335,18 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
});
|
||||
}
|
||||
}),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.start,
|
||||
onSelected: (categories) {
|
||||
pickedCategories = categories;
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
@ -35,7 +34,6 @@ class _AppPageState extends State<AppPage> {
|
||||
});
|
||||
}
|
||||
|
||||
var categories = settingsProvider.categories;
|
||||
var sourceProvider = SourceProvider();
|
||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||
@ -44,6 +42,106 @@ class _AppPageState extends State<AppPage> {
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
|
||||
|
||||
var infoColumn = Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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(
|
||||
tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'${tr('installedVersionX', args: [
|
||||
app?.app.installedVersion ?? tr('none')
|
||||
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
|
||||
tr('app')
|
||||
])}' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('lastUpdateCheckX', args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.center,
|
||||
preselected:
|
||||
app?.app.categories != null ? app!.app.categories.toSet() : {},
|
||||
onSelected: (categories) {
|
||||
if (app != null) {
|
||||
app.app.categories = categories;
|
||||
appsProvider.saveApps([app.app]);
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
var fullInfoColumn = Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 150),
|
||||
app?.installedInfo != null
|
||||
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
const SizedBox(
|
||||
height: 25,
|
||||
),
|
||||
Text(
|
||||
app?.installedInfo?.name ?? app?.app.name ?? tr('app'),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
tr('byX', args: [app?.app.author ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
infoColumn,
|
||||
const SizedBox(height: 150)
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@ -72,105 +170,8 @@ class _AppPageState extends State<AppPage> {
|
||||
: Container()
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
app?.installedInfo != null
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
const SizedBox(
|
||||
height: 25,
|
||||
),
|
||||
Text(
|
||||
app?.installedInfo?.name ??
|
||||
app?.app.name ??
|
||||
tr('app'),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
tr('byX', args: [app?.app.author ?? tr('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(
|
||||
tr('latestVersionX',
|
||||
args: [app?.app.latestVersion ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'${tr('installedVersionX', args: [
|
||||
app?.app.installedVersion ?? tr('none')
|
||||
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
|
||||
tr('app')
|
||||
])}' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('lastUpdateCheckX', args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.center,
|
||||
singleSelect: true,
|
||||
preselected: app?.app.category != null
|
||||
? {app!.app.category!}
|
||||
: {},
|
||||
onSelected: (categories) {
|
||||
if (app != null) {
|
||||
app.app.category = categories.isNotEmpty
|
||||
? categories[0]
|
||||
: null;
|
||||
appsProvider.saveApps([app.app]);
|
||||
}
|
||||
})
|
||||
],
|
||||
)),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(children: [fullInfoColumn])),
|
||||
],
|
||||
),
|
||||
onRefresh: () async {
|
||||
@ -289,6 +290,31 @@ class _AppPageState extends State<AppPage> {
|
||||
},
|
||||
tooltip: tr('additionalOptions'),
|
||||
icon: const Icon(Icons.settings)),
|
||||
if (app != null && settingsProvider.showAppWebpage)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: infoColumn,
|
||||
title: Text(
|
||||
'${app.app.name} ${tr('byX', args: [
|
||||
app.app.author
|
||||
])}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
tooltip: tr('more')),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
|
@ -7,6 +7,7 @@ import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -22,7 +23,8 @@ class AppsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class AppsPageState extends State<AppsPage> {
|
||||
AppsFilter? filter;
|
||||
AppsFilter filter = AppsFilter();
|
||||
final AppsFilter neutralFilter = AppsFilter();
|
||||
var updatesOnlyFilter =
|
||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||
Set<App> selectedApps = {};
|
||||
@ -54,7 +56,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
var sortedApps = appsProvider.apps.values.toList();
|
||||
var currentFilterIsUpdatesOnly =
|
||||
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||
filter.isIdenticalTo(updatesOnlyFilter, settingsProvider);
|
||||
|
||||
selectedApps = selectedApps
|
||||
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||
@ -70,22 +72,20 @@ class AppsPageState extends State<AppsPage> {
|
||||
});
|
||||
}
|
||||
|
||||
if (filter != null) {
|
||||
sortedApps = sortedApps.where((app) {
|
||||
if (app.app.installedVersion == app.app.latestVersion &&
|
||||
!(filter!.includeUptodate)) {
|
||||
!(filter.includeUptodate)) {
|
||||
return false;
|
||||
}
|
||||
if (app.app.installedVersion == null &&
|
||||
!(filter!.includeNonInstalled)) {
|
||||
if (app.app.installedVersion == null && !(filter.includeNonInstalled)) {
|
||||
return false;
|
||||
}
|
||||
if (filter!.nameFilter.isNotEmpty || filter!.authorFilter.isNotEmpty) {
|
||||
List<String> nameTokens = filter!.nameFilter
|
||||
if (filter.nameFilter.isNotEmpty || filter.authorFilter.isNotEmpty) {
|
||||
List<String> nameTokens = filter.nameFilter
|
||||
.split(' ')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.toList();
|
||||
List<String> authorTokens = filter!.authorFilter
|
||||
List<String> authorTokens = filter.authorFilter
|
||||
.split(' ')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.toList();
|
||||
@ -102,13 +102,14 @@ class AppsPageState extends State<AppsPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filter!.categoryFilter.isNotEmpty &&
|
||||
filter!.categoryFilter != app.app.category) {
|
||||
if (filter.categoryFilter.isNotEmpty &&
|
||||
filter.categoryFilter
|
||||
.intersection(app.app.categories.toSet())
|
||||
.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
sortedApps.sort((a, b) {
|
||||
var nameA = a.installedInfo?.name ?? a.app.name;
|
||||
@ -226,14 +227,21 @@ class AppsPageState extends State<AppsPage> {
|
||||
String? changesUrl = SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||
var transparent = const Color.fromARGB(0, 0, 0, 0).value;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.symmetric(
|
||||
vertical: BorderSide(
|
||||
width: 3,
|
||||
color: Color(settingsProvider.categories[
|
||||
sortedApps[index].app.category] ??
|
||||
const Color.fromARGB(0, 0, 0, 0).value)))),
|
||||
width: 4,
|
||||
color: Color(
|
||||
sortedApps[index].app.categories.isNotEmpty
|
||||
? settingsProvider.categories[
|
||||
sortedApps[index]
|
||||
.app
|
||||
.categories
|
||||
.first] ??
|
||||
transparent
|
||||
: transparent)))),
|
||||
child: ListTile(
|
||||
tileColor: sortedApps[index].app.pinned
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
@ -339,7 +347,21 @@ class AppsPageState extends State<AppsPage> {
|
||||
persistentFooterButtons: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
selectedApps.isEmpty
|
||||
? TextButton.icon(
|
||||
style:
|
||||
const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
selectThese(sortedApps.map((e) => e.app).toList());
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.select_all_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
label: Text(sortedApps.length.toString()))
|
||||
: TextButton.icon(
|
||||
style:
|
||||
const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
@ -351,36 +373,39 @@ class AppsPageState extends State<AppsPage> {
|
||||
: Icons.deselect_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: selectedApps.isEmpty
|
||||
? tr('selectAll')
|
||||
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||
label: Text(selectedApps.length.toString())),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('removeSelectedAppsQuestion'),
|
||||
title:
|
||||
tr('removeSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
message: tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
plural('apps', selectedApps.length)
|
||||
plural(
|
||||
'apps', selectedApps.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
appsProvider.removeApps(selectedApps
|
||||
.map((e) => e.id)
|
||||
.toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -397,50 +422,71 @@ class AppsPageState extends State<AppsPage> {
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
List<GeneratedFormItem> formItems = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('updates',
|
||||
if (existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'updates',
|
||||
label: tr('updateX', args: [
|
||||
plural('apps',
|
||||
existingUpdateIdsAllOrSelected.length)
|
||||
plural(
|
||||
'apps',
|
||||
existingUpdateIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: true));
|
||||
}
|
||||
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('installs',
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'installs',
|
||||
label: tr('installX', args: [
|
||||
plural('apps',
|
||||
newInstallIdsAllOrSelected.length)
|
||||
plural(
|
||||
'apps',
|
||||
newInstallIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: existingUpdateIdsAllOrSelected
|
||||
defaultValue:
|
||||
existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty));
|
||||
}
|
||||
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('trackonlies',
|
||||
label: tr('markXTrackOnlyAsUpdated', args: [
|
||||
plural('apps',
|
||||
trackOnlyUpdateIdsAllOrSelected.length)
|
||||
if (trackOnlyUpdateIdsAllOrSelected
|
||||
.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'trackonlies',
|
||||
label: tr('markXTrackOnlyAsUpdated',
|
||||
args: [
|
||||
plural(
|
||||
'apps',
|
||||
trackOnlyUpdateIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: existingUpdateIdsAllOrSelected
|
||||
defaultValue:
|
||||
existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty ||
|
||||
newInstallIdsAllOrSelected.isNotEmpty));
|
||||
newInstallIdsAllOrSelected
|
||||
.isNotEmpty));
|
||||
}
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var totalApps = existingUpdateIdsAllOrSelected
|
||||
var totalApps =
|
||||
existingUpdateIdsAllOrSelected.length +
|
||||
newInstallIdsAllOrSelected
|
||||
.length +
|
||||
newInstallIdsAllOrSelected.length +
|
||||
trackOnlyUpdateIdsAllOrSelected.length;
|
||||
trackOnlyUpdateIdsAllOrSelected
|
||||
.length;
|
||||
return GeneratedFormModal(
|
||||
title: tr('changeX',
|
||||
args: [plural('apps', totalApps)]),
|
||||
items: formItems.map((e) => [e]).toList(),
|
||||
title: tr('changeX', args: [
|
||||
plural('apps', totalApps)
|
||||
]),
|
||||
items: formItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
initValid: true,
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
if (values.isEmpty) {
|
||||
values = getDefaultValuesFromFormItems(
|
||||
values =
|
||||
getDefaultValuesFromFormItems(
|
||||
[formItems]);
|
||||
}
|
||||
bool shouldInstallUpdates =
|
||||
@ -459,20 +505,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
.then((_) {
|
||||
List<String> toInstall = [];
|
||||
if (shouldInstallUpdates) {
|
||||
toInstall
|
||||
.addAll(existingUpdateIdsAllOrSelected);
|
||||
toInstall.addAll(
|
||||
existingUpdateIdsAllOrSelected);
|
||||
}
|
||||
if (shouldInstallNew) {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
toInstall.addAll(
|
||||
newInstallIdsAllOrSelected);
|
||||
}
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(toInstall,
|
||||
globalNavigatorKey.currentContext)
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall,
|
||||
globalNavigatorKey
|
||||
.currentContext)
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
@ -486,30 +534,104 @@ class AppsPageState extends State<AppsPage> {
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
Set<String>? preselected;
|
||||
var showPrompt = false;
|
||||
for (var element in selectedApps) {
|
||||
var currentCats =
|
||||
element.categories.toSet();
|
||||
if (preselected == null) {
|
||||
preselected = currentCats;
|
||||
} else {
|
||||
if (!settingsProvider.setEqual(
|
||||
currentCats, preselected)) {
|
||||
showPrompt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var cont = true;
|
||||
if (showPrompt) {
|
||||
cont = await showDialog<
|
||||
Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('categorize'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
message: tr(
|
||||
'selectedCategorizeWarning'),
|
||||
);
|
||||
}) !=
|
||||
null;
|
||||
}
|
||||
if (cont) {
|
||||
await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('categorize'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
singleNullReturnButton:
|
||||
tr('continue'),
|
||||
additionalWidgets: [
|
||||
CategoryEditorSelector(
|
||||
preselected: !showPrompt
|
||||
? preselected ?? {}
|
||||
: {},
|
||||
showLabelWhenNotEmpty: false,
|
||||
onSelected: (categories) {
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.categories = categories;
|
||||
return e;
|
||||
}).toList());
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err, context);
|
||||
}
|
||||
},
|
||||
tooltip: tr('categorize'),
|
||||
icon: const Icon(Icons.category_outlined),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
padding:
|
||||
const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceAround,
|
||||
MainAxisAlignment
|
||||
.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed:
|
||||
appsProvider
|
||||
onPressed: appsProvider
|
||||
.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
@ -517,47 +639,39 @@ class AppsPageState extends State<AppsPage> {
|
||||
title: Text(tr(
|
||||
'markXSelectedAppsAsUpdated',
|
||||
args: [
|
||||
selectedApps
|
||||
.length
|
||||
.toString()
|
||||
selectedApps.length.toString()
|
||||
])),
|
||||
content: Text(
|
||||
content:
|
||||
Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic),
|
||||
FontWeight.bold,
|
||||
fontStyle: FontStyle.italic),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
tr('no'))),
|
||||
child:
|
||||
Text(tr('no'))),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion != null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
}
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
tr('yes')))
|
||||
child:
|
||||
Text(tr('yes')))
|
||||
],
|
||||
);
|
||||
}).whenComplete(() {
|
||||
@ -566,21 +680,25 @@ class AppsPageState extends State<AppsPage> {
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
tr('markSelectedAppsUpdated'),
|
||||
icon: const Icon(Icons.done)),
|
||||
tooltip: tr(
|
||||
'markSelectedAppsUpdated'),
|
||||
icon: const Icon(
|
||||
Icons.done)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
var pinStatus = selectedApps
|
||||
var pinStatus =
|
||||
selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
element
|
||||
.pinned)
|
||||
.isEmpty;
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.pinned = pinStatus;
|
||||
return e;
|
||||
}).toList());
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
tooltip: selectedApps
|
||||
.where((element) =>
|
||||
@ -592,14 +710,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty
|
||||
? Icons.bookmark_outline_rounded
|
||||
? Icons
|
||||
.bookmark_outline_rounded
|
||||
: Icons
|
||||
.bookmark_remove_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
String urls = '';
|
||||
for (var a in selectedApps) {
|
||||
for (var a
|
||||
in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
@ -607,16 +727,20 @@ class AppsPageState extends State<AppsPage> {
|
||||
Share.share(urls,
|
||||
subject: tr(
|
||||
'selectedAppURLsFromObtainium'));
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
tooltip: tr('shareSelectedAppURLs'),
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: tr(
|
||||
'shareSelectedAppURLs'),
|
||||
icon:
|
||||
const Icon(Icons.share),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
builder: (BuildContext
|
||||
ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr(
|
||||
'resetInstallStatusForSelectedAppsQuestion'),
|
||||
@ -634,18 +758,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.installedVersion = null;
|
||||
selectedApps
|
||||
.map((e) {
|
||||
e.installedVersion =
|
||||
null;
|
||||
return e;
|
||||
}).toList());
|
||||
}
|
||||
}).whenComplete(() {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip: tr('resetInstallStatus'),
|
||||
icon: const Icon(
|
||||
Icons.restore_page_outlined),
|
||||
tooltip: tr(
|
||||
'resetInstallStatus'),
|
||||
icon: const Icon(Icons
|
||||
.restore_page_outlined),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@ -656,14 +784,14 @@ class AppsPageState extends State<AppsPage> {
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
)),
|
||||
))),
|
||||
const VerticalDivider(),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (currentFilterIsUpdatesOnly) {
|
||||
filter = null;
|
||||
filter = AppsFilter();
|
||||
} else {
|
||||
filter = updatesOnlyFilter;
|
||||
}
|
||||
@ -682,10 +810,15 @@ class AppsPageState extends State<AppsPage> {
|
||||
appsProvider.apps.isEmpty
|
||||
? const SizedBox()
|
||||
: TextButton.icon(
|
||||
style:
|
||||
const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
label: Text(
|
||||
filter == null ? tr('filter') : tr('filterActive'),
|
||||
filter.isIdenticalTo(neutralFilter, settingsProvider)
|
||||
? tr('filter')
|
||||
: tr('filterActive'),
|
||||
style: TextStyle(
|
||||
fontWeight: filter == null
|
||||
fontWeight: filter.isIdenticalTo(
|
||||
neutralFilter, settingsProvider)
|
||||
? FontWeight.normal
|
||||
: FontWeight.bold),
|
||||
),
|
||||
@ -693,10 +826,9 @@ class AppsPageState extends State<AppsPage> {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var vals = filter == null
|
||||
? AppsFilter().toValuesMap()
|
||||
: filter!.toValuesMap();
|
||||
var vals = filter.toFormValuesMap();
|
||||
return GeneratedFormModal(
|
||||
initValid: true,
|
||||
title: tr('filterApps'),
|
||||
items: [
|
||||
[
|
||||
@ -718,19 +850,24 @@ class AppsPageState extends State<AppsPage> {
|
||||
GeneratedFormSwitch('nonInstalledApps',
|
||||
label: tr('nonInstalledApps'),
|
||||
defaultValue: vals['nonInstalledApps'])
|
||||
],
|
||||
[
|
||||
settingsProvider.getCategoryFormItem(
|
||||
initCategory: vals['category'] ?? '')
|
||||
]
|
||||
]);
|
||||
],
|
||||
additionalWidgets: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
preselected: filter.categoryFilter,
|
||||
onSelected: (categories) {
|
||||
filter.categoryFilter = categories.toSet();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
filter = AppsFilter.fromValuesMap(values);
|
||||
if (AppsFilter().isIdenticalTo(filter!)) {
|
||||
filter = null;
|
||||
}
|
||||
filter.setFormValuesFromMap(values);
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -748,37 +885,35 @@ class AppsFilter {
|
||||
late String authorFilter;
|
||||
late bool includeUptodate;
|
||||
late bool includeNonInstalled;
|
||||
late String categoryFilter;
|
||||
late Set<String> categoryFilter;
|
||||
|
||||
AppsFilter(
|
||||
{this.nameFilter = '',
|
||||
this.authorFilter = '',
|
||||
this.includeUptodate = true,
|
||||
this.includeNonInstalled = true,
|
||||
this.categoryFilter = ''});
|
||||
this.categoryFilter = const {}});
|
||||
|
||||
Map<String, dynamic> toValuesMap() {
|
||||
Map<String, dynamic> toFormValuesMap() {
|
||||
return {
|
||||
'appName': nameFilter,
|
||||
'author': authorFilter,
|
||||
'upToDateApps': includeUptodate,
|
||||
'nonInstalledApps': includeNonInstalled,
|
||||
'category': categoryFilter
|
||||
'nonInstalledApps': includeNonInstalled
|
||||
};
|
||||
}
|
||||
|
||||
AppsFilter.fromValuesMap(Map<String, dynamic> values) {
|
||||
setFormValuesFromMap(Map<String, dynamic> values) {
|
||||
nameFilter = values['appName']!;
|
||||
authorFilter = values['author']!;
|
||||
includeUptodate = values['upToDateApps'];
|
||||
includeNonInstalled = values['nonInstalledApps'];
|
||||
categoryFilter = values['category']!;
|
||||
}
|
||||
|
||||
bool isIdenticalTo(AppsFilter other) =>
|
||||
bool isIdenticalTo(AppsFilter other, SettingsProvider settingsProvider) =>
|
||||
authorFilter.trim() == other.authorFilter.trim() &&
|
||||
nameFilter.trim() == other.nameFilter.trim() &&
|
||||
includeUptodate == other.includeUptodate &&
|
||||
includeNonInstalled == other.includeNonInstalled &&
|
||||
categoryFilter.trim() == other.categoryFilter.trim();
|
||||
settingsProvider.setEqual(categoryFilter, other.categoryFilter);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@ -28,6 +29,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
StadiumBorder(
|
||||
@ -66,6 +68,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
showError(
|
||||
tr('exportedTo', args: [path]),
|
||||
context);
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
child: Text(tr('obtainiumExport')))),
|
||||
@ -98,6 +102,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
var cats =
|
||||
settingsProvider.categories;
|
||||
appsProvider.apps
|
||||
.forEach((key, value) {
|
||||
for (var c
|
||||
in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] =
|
||||
generateRandomLightColor()
|
||||
.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
settingsProvider.categories =
|
||||
cats;
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', value)
|
||||
|
@ -185,7 +185,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return [e];
|
||||
}).toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (valid) {
|
||||
if (valid && !isBuilding) {
|
||||
values.forEach((key, value) {
|
||||
settingsProvider.setSettingString(key, value);
|
||||
});
|
||||
@ -286,7 +286,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
height16,
|
||||
const CategoryEditorSelector()
|
||||
const CategoryEditorSelector(
|
||||
showLabelWhenNotEmpty: false,
|
||||
)
|
||||
],
|
||||
))),
|
||||
SliverToBoxAdapter(
|
||||
@ -407,12 +409,14 @@ class CategoryEditorSelector extends StatefulWidget {
|
||||
final bool singleSelect;
|
||||
final Set<String> preselected;
|
||||
final WrapAlignment alignment;
|
||||
final bool showLabelWhenNotEmpty;
|
||||
const CategoryEditorSelector(
|
||||
{super.key,
|
||||
this.onSelected,
|
||||
this.singleSelect = false,
|
||||
this.preselected = const {},
|
||||
this.alignment = WrapAlignment.start});
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.showLabelWhenNotEmpty = true});
|
||||
|
||||
@override
|
||||
State<CategoryEditorSelector> createState() => _CategoryEditorSelectorState();
|
||||
@ -432,14 +436,15 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTagInput('categories',
|
||||
label: tr('category'),
|
||||
label: tr('categories'),
|
||||
emptyMessage: tr('noCategories'),
|
||||
defaultValue: storedValues,
|
||||
alignment: widget.alignment,
|
||||
deleteConfirmationMessage: MapEntry(
|
||||
tr('deleteCategoriesQuestion'),
|
||||
tr('categoryDeleteWarning')),
|
||||
singleSelect: widget.singleSelect)
|
||||
singleSelect: widget.singleSelect,
|
||||
showLabelWhenNotEmpty: widget.showLabelWhenNotEmpty)
|
||||
]
|
||||
],
|
||||
onValueChanges: ((values, valid, isBuilding) {
|
||||
|
@ -17,6 +17,7 @@ import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:package_archive_info/package_archive_info.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||
@ -246,10 +247,7 @@ class AppsProvider with ChangeNotifier {
|
||||
!(await canDowngradeApps())) {
|
||||
throw DowngradeError();
|
||||
}
|
||||
if (appInfo == null ||
|
||||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
}
|
||||
apps[file.appId]!.app.installedVersion =
|
||||
apps[file.appId]!.app.latestVersion;
|
||||
// Don't correct install status as installation may not be done yet
|
||||
@ -706,6 +704,14 @@ class AppsProvider with ChangeNotifier {
|
||||
exportDir = await getExternalStorageDirectory();
|
||||
path = exportDir!.path;
|
||||
}
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 28) {
|
||||
if (await Permission.storage.isDenied) {
|
||||
await Permission.storage.request();
|
||||
}
|
||||
if (await Permission.storage.isDenied) {
|
||||
throw ObtainiumError(tr('storagePermissionDenied'));
|
||||
}
|
||||
}
|
||||
File export = File(
|
||||
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
export.writeAsStringSync(
|
||||
|
@ -157,15 +157,6 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
getCategoryFormItem({String initCategory = ''}) => GeneratedFormDropdown(
|
||||
'category',
|
||||
label: tr('category'),
|
||||
[
|
||||
MapEntry('', tr('noCategory')),
|
||||
...categories.entries.map((e) => MapEntry(e.key, e.key)).toList()
|
||||
],
|
||||
defaultValue: initCategory);
|
||||
|
||||
String? get forcedLocale {
|
||||
var fl = prefs?.getString('forcedLocale');
|
||||
return supportedLocales
|
||||
@ -185,4 +176,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool setEqual(Set<String> a, Set<String> b) =>
|
||||
a.length == b.length && a.union(b).length == a.length;
|
||||
}
|
||||
|
@ -7,11 +7,13 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/codeberg.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/fdroidrepo.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/html.dart';
|
||||
import 'package:obtainium/app_sources/mullvad.dart';
|
||||
import 'package:obtainium/app_sources/signal.dart';
|
||||
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||
@ -19,7 +21,6 @@ import 'package:obtainium/app_sources/steammobile.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
|
||||
class AppNames {
|
||||
late String author;
|
||||
@ -48,7 +49,7 @@ class App {
|
||||
late Map<String, dynamic> additionalSettings;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
String? category;
|
||||
List<String> categories;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -61,7 +62,7 @@ class App {
|
||||
this.additionalSettings,
|
||||
this.lastUpdateCheck,
|
||||
this.pinned,
|
||||
{this.category});
|
||||
{this.categories = const []});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@ -103,6 +104,12 @@ class App {
|
||||
item.ensureType(additionalSettings[item.key]);
|
||||
}
|
||||
}
|
||||
int preferredApkIndex = json['preferredApkIndex'] == null
|
||||
? 0
|
||||
: json['preferredApkIndex'] as int;
|
||||
if (preferredApkIndex < 0) {
|
||||
preferredApkIndex = 0;
|
||||
}
|
||||
return App(
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
@ -115,15 +122,19 @@ class App {
|
||||
json['apkUrls'] == null
|
||||
? []
|
||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null
|
||||
? 0
|
||||
: json['preferredApkIndex'] as int,
|
||||
preferredApkIndex,
|
||||
additionalSettings,
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false,
|
||||
category: json['category']);
|
||||
categories: json['categories'] != null
|
||||
? (json['categories'] as List<dynamic>)
|
||||
.map((e) => e.toString())
|
||||
.toList()
|
||||
: json['category'] != null
|
||||
? [json['category'] as String]
|
||||
: []);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@ -138,12 +149,16 @@ class App {
|
||||
'additionalSettings': jsonEncode(additionalSettings),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned,
|
||||
'category': category
|
||||
'categories': categories
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the input is starts with HTTPS and has no WWW
|
||||
preStandardizeUrl(String url) {
|
||||
var firstDotIndex = url.indexOf('.');
|
||||
if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||
url.toLowerCase().indexOf('https://') != 0) {
|
||||
url = 'https://$url';
|
||||
@ -259,6 +274,7 @@ class SourceProvider {
|
||||
List<AppSource> sources = [
|
||||
GitHub(),
|
||||
GitLab(),
|
||||
Codeberg(),
|
||||
FDroid(),
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
@ -266,7 +282,8 @@ class SourceProvider {
|
||||
SourceForge(),
|
||||
APKMirror(),
|
||||
FDroidRepo(),
|
||||
SteamMobile()
|
||||
SteamMobile(),
|
||||
HTML() // This should ALWAYS be the last option as they are tried in order
|
||||
];
|
||||
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
@ -360,11 +377,11 @@ class SourceProvider {
|
||||
currentApp?.installedVersion,
|
||||
apkVersion,
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
apk.apkUrls.length - 1 >= 0 ? apk.apkUrls.length - 1 : 0,
|
||||
additionalSettings,
|
||||
DateTime.now(),
|
||||
currentApp?.pinned ?? false,
|
||||
category: currentApp?.category);
|
||||
categories: currentApp?.categories ?? const []);
|
||||
}
|
||||
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
|
41
pubspec.lock
41
pubspec.lock
@ -56,7 +56,7 @@ packages:
|
||||
name: checked_yaml
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "2.0.2"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -182,7 +182,7 @@ packages:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.4"
|
||||
version: "5.2.5"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -286,7 +286,7 @@ packages:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "3.3.0"
|
||||
install_plugin_v2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -356,7 +356,7 @@ packages:
|
||||
name: mime
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.0.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -419,7 +419,7 @@ packages:
|
||||
name: path_provider_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.6"
|
||||
version: "2.0.7"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -531,7 +531,7 @@ packages:
|
||||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.15"
|
||||
version: "2.0.16"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -539,10 +539,10 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.14"
|
||||
shared_preferences_ios:
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_ios
|
||||
name: shared_preferences_foundation
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
@ -553,13 +553,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
shared_preferences_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -599,14 +592,14 @@ packages:
|
||||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
version: "2.2.3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
version: "2.4.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -634,7 +627,7 @@ packages:
|
||||
name: synchronized
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0+3"
|
||||
version: "3.0.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -655,7 +648,7 @@ packages:
|
||||
name: timezone
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.0"
|
||||
version: "0.9.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -739,28 +732,28 @@ packages:
|
||||
name: webview_flutter
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.0.1"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.1.3"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.0.1"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.2"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -774,7 +767,7 @@ packages:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0+2"
|
||||
version: "0.2.0+3"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -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
|
||||
# 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.
|
||||
version: 0.9.8+96 # When changing this, update the tag in main() accordingly
|
||||
version: 0.10.2+108 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
|
Reference in New Issue
Block a user