Compare commits

...

18 Commits

Author SHA1 Message Date
2ff6acb701 Merge pull request #182 from ImranR98/dev
Broke `GeneratedFormItem` into sub-types + bugfix
2022-12-21 18:26:15 -05:00
0c2d6ce84d Increment version 2022-12-21 18:23:55 -05:00
9072862862 Broke GeneratedFormItem into sub-types
Prep for "chips" input type
2022-12-21 18:23:25 -05:00
3cbaac2f5d Increment version 2022-12-21 15:08:18 -05:00
0f8871efcb Merge pull request #179 from bluefly000/japanese-translation
Update Japanese translation
2022-12-21 15:07:33 -05:00
ee216cbbba Merge pull request #181 from ImranR98/dev
Bugfix (#178) + translation typos
2022-12-21 15:07:26 -05:00
ebe5b79dc5 Bugfix (#178) + translation typos 2022-12-21 15:06:54 -05:00
60014c864c Update Japanese translation 2022-12-21 21:48:32 +09:00
070b6033bd Merge pull request #177 from ImranR98/dev
Added very basic categorization support
2022-12-21 04:25:45 -05:00
626bebbe5a Localized new strings 2022-12-21 04:24:17 -05:00
118460ccb9 Added category filter 2022-12-21 04:15:39 -05:00
26f953dbb0 Category displayed on App/Apps pages
+ category save bugfix
2022-12-21 03:57:08 -05:00
99d7595f2d Added category add/remove (no recolour/rename for now) 2022-12-21 03:08:56 -05:00
e2f99c5e71 Increment version 2022-12-20 21:12:50 -05:00
1f582d239b Merge pull request #175 from RanTranslations/main
assests: Update Simplified Chinese
2022-12-20 21:10:48 -05:00
5e6b00718e Merge pull request #176 from bluefly000/main
Update Japanese translation
2022-12-20 21:10:26 -05:00
56594e6b19 Update Japanese translation 2022-12-21 10:44:18 +09:00
bbcc3ff9b3 assests: Update Simplified Chinese 2022-12-21 09:07:31 +08:00
28 changed files with 652 additions and 344 deletions

View File

@ -135,7 +135,7 @@
"appearance": "Aussehen", "appearance": "Aussehen",
"showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen", "showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen",
"pinUpdates": "Apps mit Aktualisierungen oben anheften", "pinUpdates": "Apps mit Aktualisierungen oben anheften",
"updates": "Aktualisiert", "updates": "Aktualisierungen",
"sourceSpecific": "Quellenspezifisch", "sourceSpecific": "Quellenspezifisch",
"appSource": "App-Quelle", "appSource": "App-Quelle",
"noLogs": "Keine Protokolle", "noLogs": "Keine Protokolle",
@ -199,6 +199,14 @@
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found", "noAPKFound": "No APK found",
"noVersionDetection": "No version detection", "noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut", "one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut" "other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"

View File

@ -135,7 +135,7 @@
"appearance": "Appearance", "appearance": "Appearance",
"showWebInAppView": "Show Source Webpage in App View", "showWebInAppView": "Show Source Webpage in App View",
"pinUpdates": "Pin Updates to Top of Apps View", "pinUpdates": "Pin Updates to Top of Apps View",
"updates": "Updated", "updates": "Updates",
"sourceSpecific": "Source-Specific", "sourceSpecific": "Source-Specific",
"appSource": "App Source", "appSource": "App Source",
"noLogs": "No Logs", "noLogs": "No Logs",
@ -199,6 +199,14 @@
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found", "noAPKFound": "No APK found",
"noVersionDetection": "No version detection", "noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "Too many requests (rate limited) - try again in {} minute", "one": "Too many requests (rate limited) - try again in {} minute",
"other": "Too many requests (rate limited) - try again in {} minutes" "other": "Too many requests (rate limited) - try again in {} minutes"

View File

@ -135,7 +135,7 @@
"appearance": "Megjelenés", "appearance": "Megjelenés",
"showWebInAppView": "Forrás megjelenítése az Appok nézetben", "showWebInAppView": "Forrás megjelenítése az Appok nézetben",
"pinUpdates": "Frissítések kitűzése az App nézet tetejére", "pinUpdates": "Frissítések kitűzése az App nézet tetejére",
"updates": "Frissítve", "updates": "Frissítések",
"sourceSpecific": "Forrás-specifikus", "sourceSpecific": "Forrás-specifikus",
"appSource": "App forrás", "appSource": "App forrás",
"noLogs": "Nincsenek naplók", "noLogs": "Nincsenek naplók",
@ -199,6 +199,14 @@
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found", "noAPKFound": "No APK found",
"noVersionDetection": "No version detection", "noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva", "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" "other": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva"

View File

@ -135,7 +135,7 @@
"appearance": "Aspetto", "appearance": "Aspetto",
"showWebInAppView": "Mostra pagina web dell'App se selezionata", "showWebInAppView": "Mostra pagina web dell'App se selezionata",
"pinUpdates": "Fissa in alto gli aggiornamenti disponibili", "pinUpdates": "Fissa in alto gli aggiornamenti disponibili",
"updates": "Aggiornato", "updates": "Aggiornamenti",
"sourceSpecific": "Specifiche per la fonte", "sourceSpecific": "Specifiche per la fonte",
"appSource": "Sorgente dell'App", "appSource": "Sorgente dell'App",
"noLogs": "Nessun log", "noLogs": "Nessun log",
@ -199,6 +199,14 @@
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found", "noAPKFound": "No APK found",
"noVersionDetection": "No version detection", "noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto", "one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti" "other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"

View File

@ -16,7 +16,7 @@
"startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始", "startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始",
"bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了", "bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了",
"firstRun": "これがObtainiumの最初の実行です", "firstRun": "これがObtainiumの最初の実行です",
"settingUpdateCheckIntervalTo": "更新間隔を{}に設定する", "settingUpdateCheckIntervalTo": "確認間隔を{}に設定する",
"githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)", "githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)",
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン", "githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
"githubPATFormat": "ユーザー名:トークン", "githubPATFormat": "ユーザー名:トークン",
@ -29,16 +29,16 @@
"cancel": "キャンセル", "cancel": "キャンセル",
"continue": "続ける", "continue": "続ける",
"requiredInBrackets": "(必須)", "requiredInBrackets": "(必須)",
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのoptが必要です", "dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのオプションが必要です",
"colour": "カラー", "colour": "カラー",
"githubStarredRepos": "Githubでスターしたリポジトリ", "githubStarredRepos": "Githubでスターしたリポジトリ",
"uname": "ユーザー名", "uname": "ユーザー名",
"wrongArgNum": "提供する引数の数が間違っています", "wrongArgNum": "提供する引数の数が間違っています",
"xIsTrackOnly": "{} は'追跡のみ'です", "xIsTrackOnly": "{} は追跡のみです",
"source": "ソース", "source": "ソース",
"app": "アプリ", "app": "アプリ",
"appsFromSourceAreTrackOnly": "このソースからのアプリは'追跡のみ'です'。", "appsFromSourceAreTrackOnly": "このソースからのアプリは追跡のみです。",
"youPickedTrackOnly": "'追跡のみ'を選択しています", "youPickedTrackOnly": "追跡のみを選択しています",
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。", "trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
"cancelled": "キャンセルしました", "cancelled": "キャンセルしました",
"appAlreadyAdded": "アプリはすでに追加されています", "appAlreadyAdded": "アプリはすでに追加されています",
@ -124,13 +124,13 @@
"obtainium": "Obtainium", "obtainium": "Obtainium",
"materialYou": "Material You", "materialYou": "Material You",
"appSortBy": "アプリの並び方", "appSortBy": "アプリの並び方",
"authorName": "作者/アプリ名", "authorName": "作者/アプリ名",
"nameAuthor": "アプリ名/作者", "nameAuthor": "アプリ名/作者",
"asAdded": "追加順", "asAdded": "追加順",
"appSortOrder": "並び順", "appSortOrder": "並び順",
"ascending": "昇順", "ascending": "昇順",
"descending": "降順", "descending": "降順",
"bgUpdateCheckInterval": "バックグラウンド更新の確認間隔", "bgUpdateCheckInterval": "バックグラウンドでのアップデート確認間隔",
"neverManualOnly": "手動", "neverManualOnly": "手動",
"appearance": "外観", "appearance": "外観",
"showWebInAppView": "アプリビューにソースウェブページを表示する", "showWebInAppView": "アプリビューにソースウェブページを表示する",
@ -152,11 +152,11 @@
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?", "sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
"updatesAvailable": "アップデートが利用可能", "updatesAvailable": "アップデートが利用可能",
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する", "updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する",
"noNewUpdates": "新しいアップデートはありません", "noNewUpdates": "新しいアップデートはありません",
"xHasAnUpdate": "{}のアップデートが利用可能です", "xHasAnUpdate": "{}のアップデートが利用可能です",
"appsUpdated": "アプリをアップデートしました", "appsUpdated": "アプリをアップデートしました",
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する", "appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
"xWasUpdatedToY": "{}が{}にアップデートされました", "xWasUpdatedToY": "{}が{}にアップデートされました",
"errorCheckingUpdates": "アップデート確認中のエラー", "errorCheckingUpdates": "アップデート確認中のエラー",
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知", "errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知",
"appsRemoved": "削除されたアプリ", "appsRemoved": "削除されたアプリ",
@ -173,7 +173,7 @@
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)", "versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
"unknown": "不明", "unknown": "不明",
"none": "なし", "none": "なし",
"never": "Never", "never": "しない",
"latestVersionX": "最新のバージョン: {}", "latestVersionX": "最新のバージョン: {}",
"installedVersionX": "インストールされたバージョン: {}", "installedVersionX": "インストールされたバージョン: {}",
"lastUpdateCheckX": "最終アップデート確認: {}", "lastUpdateCheckX": "最終アップデート確認: {}",
@ -181,35 +181,43 @@
"removeAppQuestion": "アプリを削除しますか?", "removeAppQuestion": "アプリを削除しますか?",
"yesMarkUpdated": "はい、アップデート済みとしてマークします", "yesMarkUpdated": "はい、アップデート済みとしてマークします",
"fdroid": "F-Droid", "fdroid": "F-Droid",
"appIdOrName": "アプリIDまたは名前", "appIdOrName": "アプリIDまたは名前",
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした", "appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります", "reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo", "fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam", "steam": "Steam",
"steamMobile": "Steam Mobile", "steamMobile": "Steam Mobile",
"steamChat": "Steam Chat", "steamChat": "Steam Chat",
"install": "Install", "install": "インストール",
"markInstalled": "Mark Installed", "markInstalled": "インストール済みとしてマークする",
"update": "Update", "update": "アップデート",
"markUpdated": "Mark Updated", "markUpdated": "アップデート済みとしてマークする",
"additionalOptions": "Additional Options", "additionalOptions": "追加のオプション",
"disableVersionDetection": "Disable Version Detection", "disableVersionDetection": "バージョン検出を無効にする",
"noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.", "noVersionDetectionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリにのみ使用する必要があります。",
"downloadingX": "Downloading {}", "downloadingX": "{} をダウンロード中",
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "アプリのダウンロード状況を通知する",
"noAPKFound": "No APK found", "noAPKFound": "APKが見つかりません",
"noVersionDetection": "No version detection", "noVersionDetection": "バージョン検出を行わない",
"categorize": "カテゴライズ",
"categories": "カテゴリ",
"category": "カテゴリ",
"noCategory": "カテゴリなし",
"deleteCategoryQuestion": "カテゴリを削除しますか?",
"categoryDeleteWarning": "「{}」内のすべてのアプリは未分類に設定されます。",
"addCategory": "カテゴリを追加",
"label": "ラベル",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください", "one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください" "other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
}, },
"bgUpdateGotErrorRetryInMinutes": { "bgUpdateGotErrorRetryInMinutes": {
"one": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します", "one": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します" "other": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します"
}, },
"bgCheckFoundUpdatesWillNotifyIfNeeded": { "bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します", "one": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します" "other": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
}, },
"apps": { "apps": {
"one": "{}個のアプリ", "one": "{}個のアプリ",
@ -236,11 +244,11 @@
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})" "other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
}, },
"xAndNMoreUpdatesAvailable": { "xAndNMoreUpdatesAvailable": {
"one": "{}とさらに{}個のアプリのアップデートが利用可能です", "one": "{}とさらに{}個のアプリのアップデートが利用可能です",
"other": "{}とさらに{}個のアプリのアップデートが利用可能です" "other": "{}とさらに{}個のアプリのアップデートが利用可能です"
}, },
"xAndNMoreUpdatesInstalled": { "xAndNMoreUpdatesInstalled": {
"one": "{}とさらに{}個のアプリがアップデートされました", "one": "{}とさらに{}個のアプリがアップデートされました",
"other": "{}とさらに{}個のアプリがアップデートされました" "other": "{}とさらに{}個のアプリがアップデートされました"
} }
} }

View File

@ -22,9 +22,9 @@
"githubPATFormat": "username:token", "githubPATFormat": "username:token",
"githubPATLinkText": "关于 GitHub 个人访问令牌", "githubPATLinkText": "关于 GitHub 个人访问令牌",
"includePrereleases": "包含预发布版", "includePrereleases": "包含预发布版",
"fallbackToOlderReleases": "回到旧版", "fallbackToOlderReleases": "回退到旧版",
"filterReleaseTitlesByRegEx": "通过正则表达式过滤发布标题", "filterReleaseTitlesByRegEx": "使用正则以过滤发布标题",
"invalidRegEx": "无效的正则表达式", "invalidRegEx": "表达式无效",
"noDescription": "无描述", "noDescription": "无描述",
"cancel": "取消", "cancel": "取消",
"continue": "继续", "continue": "继续",
@ -109,7 +109,7 @@
"searchX": "搜索 {}", "searchX": "搜索 {}",
"noResults": "无结果", "noResults": "无结果",
"importX": "导入 {}", "importX": "导入 {}",
"importedAppsIdDisclaimer": "导入的应用程序可能不正确地显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。", "importedAppsIdDisclaimer": "导入的应用程序可能显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
"importErrors": "导入错误", "importErrors": "导入错误",
"importedXOfYApps": "{} 中的 {} 个应用已导入", "importedXOfYApps": "{} 中的 {} 个应用已导入",
"followingURLsHadErrors": "以下 URL 有错误:", "followingURLsHadErrors": "以下 URL 有错误:",
@ -134,7 +134,7 @@
"neverManualOnly": "手动", "neverManualOnly": "手动",
"appearance": "外观", "appearance": "外观",
"showWebInAppView": "在应用来源页显示网页", "showWebInAppView": "在应用来源页显示网页",
"pinUpdates": "将需要更新的应用固定到顶部", "pinUpdates": "更新的应用置顶",
"updates": "检查间隔", "updates": "检查间隔",
"sourceSpecific": "Github 访问令牌", "sourceSpecific": "Github 访问令牌",
"appSource": "源代码", "appSource": "源代码",
@ -145,9 +145,9 @@
"appNotFound": "未找到应用", "appNotFound": "未找到应用",
"obtainiumExportHyphenatedLowercase": "obtainium-导出", "obtainiumExportHyphenatedLowercase": "obtainium-导出",
"pickAnAPK": "选择一个安装包", "pickAnAPK": "选择一个安装包",
"appHasMoreThanOnePackage": "{} 有多于一个安装包", "appHasMoreThanOnePackage": "{} 有多个架构可用",
"deviceSupportsXArch": "你的设备支持 {} CPU 架构", "deviceSupportsXArch": "你的设备支持 {} 架构",
"deviceSupportsFollowingArchs": "你的设备支持以下 CPU 架构:", "deviceSupportsFollowingArchs": "你的设备支持以下架构:",
"warning": "警告", "warning": "警告",
"sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?", "sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?",
"updatesAvailable": "更新可用", "updatesAvailable": "更新可用",
@ -188,17 +188,25 @@
"steam": "Steam", "steam": "Steam",
"steamMobile": "Steam Mobile", "steamMobile": "Steam Mobile",
"steamChat": "Steam Chat", "steamChat": "Steam Chat",
"install": "Install", "install": "安装",
"markInstalled": "Mark Installed", "markInstalled": "标记为已安装",
"update": "Update", "update": "更新",
"markUpdated": "Mark Updated", "markUpdated": "标记为已更新",
"additionalOptions": "Additional Options", "additionalOptions": "附加选项",
"disableVersionDetection": "Disable Version Detection", "disableVersionDetection": "关闭版本检测",
"noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.", "noVersionDetectionExplanation": "此选项应只用于版本检测不能工作的应用程序",
"downloadingX": "Downloading {}", "downloadingX": "下载中 {}",
"downloadNotifDescription": "Notifies the user of the progress in downloading an App", "downloadNotifDescription": "通知用户下载进度",
"noAPKFound": "No APK found", "noAPKFound": "未找到安装包",
"noVersionDetection": "No version detection", "noVersionDetection": "无版本检测",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": { "tooManyRequestsTryAgainInMinutes": {
"one": "请求过多 (API 限制) - 在 {} 分钟后重试", "one": "请求过多 (API 限制) - 在 {} 分钟后重试",
"other": "请求过多 (API 限制) - 在 {} 分钟后重试" "other": "请求过多 (API 限制) - 在 {} 分钟后重试"

View File

@ -26,7 +26,7 @@ class APKMirror extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = await get(Uri.parse('$standardUrl/feed')); Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode == 200) { if (res.statusCode == 200) {

View File

@ -32,7 +32,7 @@ class FDroid extends AppSource {
@override @override
String? tryInferringAppId(String standardUrl, String? tryInferringAppId(String standardUrl,
{Map<String, String> additionalSettings = const {}}) { {Map<String, dynamic> additionalSettings = const {}}) {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
@ -61,7 +61,7 @@ class FDroid extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
String? appId = tryInferringAppId(standardUrl); String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse( return getAPKUrlsFromFDroidPackagesAPIResponse(

View File

@ -11,7 +11,7 @@ class FDroidRepo extends AppSource {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormItem('appIdOrName', GeneratedFormTextField('appIdOrName',
label: tr('appIdOrName'), label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'), hint: tr('reposHaveMultipleApps'),
required: true) required: true)
@ -33,7 +33,7 @@ class FDroidRepo extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
String? appIdOrName = additionalSettings['appIdOrName']; String? appIdOrName = additionalSettings['appIdOrName'];
if (appIdOrName == null) { if (appIdOrName == null) {

View File

@ -13,7 +13,7 @@ class GitHub extends AppSource {
host = 'github.com'; host = 'github.com';
additionalSourceSpecificSettingFormItems = [ additionalSourceSpecificSettingFormItems = [
GeneratedFormItem('github-creds', GeneratedFormTextField('github-creds',
label: tr('githubPATLabel'), label: tr('githubPATLabel'),
required: false, required: false,
additionalValidators: [ additionalValidators: [
@ -51,21 +51,16 @@ class GitHub extends AppSource {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormItem('includePrereleases', GeneratedFormSwitch('includePrereleases',
label: tr('includePrereleases'), label: tr('includePrereleases'), defaultValue: false)
type: FormItemType.bool,
defaultValue: '')
], ],
[ [
GeneratedFormItem('fallbackToOlderReleases', GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), label: tr('fallbackToOlderReleases'), defaultValue: true)
type: FormItemType.bool,
defaultValue: 'true')
], ],
[ [
GeneratedFormItem('filterReleaseTitlesByRegEx', GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'), label: tr('filterReleaseTitlesByRegEx'),
type: FormItemType.string,
required: false, required: false,
additionalValidators: [ additionalValidators: [
(value) { (value) {
@ -111,13 +106,15 @@ class GitHub extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
var includePrereleases = additionalSettings['includePrereleases'] == 'true'; bool includePrereleases = additionalSettings['includePrereleases'];
var fallbackToOlderReleases = bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == 'true'; additionalSettings['fallbackToOlderReleases'];
var regexFilter = String? regexFilter =
additionalSettings['filterReleaseTitlesByRegEx']?.isNotEmpty == true (additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx'] ? additionalSettings['filterReleaseTitlesByRegEx']
: null; : null;
Response res = await get(Uri.parse( Response res = await get(Uri.parse(
@ -150,7 +147,7 @@ class GitHub extends AppSource {
continue; continue;
} }
var apkUrls = getReleaseAPKUrls(releases[i]); var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != 'true') { if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
continue; continue;
} }
targetRelease = releases[i]; targetRelease = releases[i];

View File

@ -26,7 +26,7 @@ class GitLab extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) 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) {

View File

@ -23,14 +23,14 @@ class IzzyOnDroid extends AppSource {
@override @override
String? tryInferringAppId(String standardUrl, String? tryInferringAppId(String standardUrl,
{Map<String, String> additionalSettings = const {}}) { {Map<String, dynamic> additionalSettings = const {}}) {
return FDroid().tryInferringAppId(standardUrl); return FDroid().tryInferringAppId(standardUrl);
} }
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
String? appId = tryInferringAppId(standardUrl); String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(

View File

@ -25,7 +25,7 @@ class Mullvad extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) 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) {

View File

@ -19,7 +19,7 @@ class Signal extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) 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'));

View File

@ -24,7 +24,7 @@ class SourceForge extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/')); Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
if (res.statusCode == 200) { if (res.statusCode == 200) {

View File

@ -10,10 +10,7 @@ class SteamMobile extends AppSource {
host = 'store.steampowered.com'; host = 'store.steampowered.com';
name = tr('steam'); name = tr('steam');
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [GeneratedFormDropdown('app', apks.entries.toList(), label: tr('app'))]
GeneratedFormItem('app',
label: tr('app'), required: true, opts: apks.entries.toList())
]
]; ];
} }
@ -30,11 +27,11 @@ class SteamMobile extends AppSource {
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, String> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = await get(Uri.parse('https://$host/mobile')); Response res = await get(Uri.parse('https://$host/mobile'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var apkNamePrefix = additionalSettings['app']; var apkNamePrefix = additionalSettings['app'] as String?;
if (apkNamePrefix == null) { if (apkNamePrefix == null) {
throw NoReleasesError(); throw NoReleasesError();
} }

View File

@ -1,39 +1,90 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum FormItemType { string, bool } abstract class GeneratedFormItem {
typedef OnValueChanges = void Function(
Map<String, String> values, bool valid, bool isBuilding);
class GeneratedFormItem {
late String key; late String key;
late String label; late String label;
late FormItemType type;
late bool required;
late int max;
late List<String? Function(String? value)> additionalValidators;
late List<Widget> belowWidgets; late List<Widget> belowWidgets;
late String? hint; late dynamic defaultValue;
late List<MapEntry<String, String>>? opts; List<dynamic> additionalValidators;
late String? defaultValue; dynamic ensureType(dynamic val);
GeneratedFormItem(this.key, GeneratedFormItem(this.key,
{this.label = 'Input', {this.label = 'Input',
this.type = FormItemType.string, this.belowWidgets = const [],
this.defaultValue,
this.additionalValidators = const []});
}
class GeneratedFormTextField extends GeneratedFormItem {
late bool required;
late int max;
late String? hint;
GeneratedFormTextField(String key,
{String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
List<String? Function(String? value)> additionalValidators = const [],
this.required = true, this.required = true,
this.max = 1, this.max = 1,
this.additionalValidators = const [], this.hint})
this.belowWidgets = const [], : super(key,
this.hint, label: label,
this.opts, belowWidgets: belowWidgets,
this.defaultValue}) { defaultValue: defaultValue,
if (type != FormItemType.string) { additionalValidators: additionalValidators);
required = false;
} @override
String ensureType(val) {
return val.toString();
} }
} }
class GeneratedFormDropdown extends GeneratedFormItem {
late List<MapEntry<String, String>>? opts;
GeneratedFormDropdown(
String key,
this.opts, {
String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
List<String? Function(String? value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
String ensureType(val) {
return val.toString();
}
}
class GeneratedFormSwitch extends GeneratedFormItem {
GeneratedFormSwitch(
String key, {
String label = 'Input',
List<Widget> belowWidgets = const [],
bool defaultValue = false,
List<String? Function(bool value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
bool ensureType(val) {
return val == true || val == 'true';
}
}
typedef OnValueChanges = void Function(
Map<String, dynamic> values, bool valid, bool isBuilding);
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
const GeneratedForm( const GeneratedForm(
{super.key, required this.items, required this.onValueChanges}); {super.key, required this.items, required this.onValueChanges});
@ -47,18 +98,16 @@ class GeneratedForm extends StatefulWidget {
class _GeneratedFormState extends State<GeneratedForm> { class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
Map<String, String> values = {}; Map<String, dynamic> values = {};
late List<List<Widget>> formInputs; late List<List<Widget>> formInputs;
List<List<Widget>> rows = []; List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity // If any value changes, call this to update the parent with value and validity
void someValueChanged({bool isBuilding = false}) { void someValueChanged({bool isBuilding = false}) {
Map<String, String> returnValues = {}; Map<String, dynamic> returnValues = values;
var valid = true; var valid = true;
for (int r = 0; r < widget.items.length; r++) { for (int r = 0; r < widget.items.length; r++) {
for (int i = 0; i < widget.items[r].length; i++) { for (int i = 0; i < widget.items[r].length; i++) {
returnValues[widget.items[r][i].key] =
values[widget.items[r][i].key] ?? '';
if (formInputs[r][i] is TextFormField) { if (formInputs[r][i] is TextFormField) {
valid = valid && valid = valid &&
((formInputs[r][i].key as GlobalKey<FormFieldState>) ((formInputs[r][i].key as GlobalKey<FormFieldState>)
@ -80,35 +129,37 @@ class _GeneratedFormState extends State<GeneratedForm> {
int j = 0; int j = 0;
for (var row in widget.items) { for (var row in widget.items) {
for (var e in row) { for (var e in row) {
values[e.key] = e.defaultValue ?? e.opts?.first.key ?? ''; values[e.key] = e.defaultValue;
} }
} }
// Dynamically create form inputs // Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) { formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) { return row.value.asMap().entries.map((e) {
if (e.value.type == FormItemType.string && e.value.opts == null) { var formItem = e.value;
if (formItem is GeneratedFormTextField) {
final formFieldKey = GlobalKey<FormFieldState>(); final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField( return TextFormField(
key: formFieldKey, key: formFieldKey,
initialValue: values[e.value.key], initialValue: values[formItem.key],
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[e.value.key] = value; values[formItem.key] = value;
someValueChanged(); someValueChanged();
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? ' *' : ''), helperText: formItem.label + (formItem.required ? ' *' : ''),
hintText: e.value.hint), hintText: formItem.hint),
minLines: e.value.max <= 1 ? null : e.value.max, minLines: formItem.max <= 1 ? null : formItem.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max, maxLines: formItem.max <= 1 ? 1 : formItem.max,
validator: (value) { validator: (value) {
if (e.value.required && (value == null || value.trim().isEmpty)) { if (formItem.required &&
return '${e.value.label} ${tr('requiredInBrackets')}'; (value == null || value.trim().isEmpty)) {
return '${formItem.label} ${tr('requiredInBrackets')}';
} }
for (var validator in e.value.additionalValidators) { for (var validator in formItem.additionalValidators) {
String? result = validator(value); String? result = validator(value);
if (result != null) { if (result != null) {
return result; return result;
@ -117,21 +168,20 @@ class _GeneratedFormState extends State<GeneratedForm> {
return null; return null;
}, },
); );
} else if (e.value.type == FormItemType.string && } else if (formItem is GeneratedFormDropdown) {
e.value.opts != null) { if (formItem.opts!.isEmpty) {
if (e.value.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError')); return Text(tr('dropdownNoOptsError'));
} }
return DropdownButtonFormField( return DropdownButtonFormField(
decoration: InputDecoration(labelText: e.value.label), decoration: InputDecoration(labelText: formItem.label),
value: values[e.value.key], value: values[formItem.key],
items: e.value.opts! items: formItem.opts!
.map((e) => .map((e2) =>
DropdownMenuItem(value: e.key, child: Text(e.value))) DropdownMenuItem(value: e2.key, child: Text(e2.value)))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[e.value.key] = value ?? e.value.opts!.first.key; values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged(); someValueChanged();
}); });
}); });
@ -147,16 +197,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
for (var r = 0; r < formInputs.length; r++) { for (var r = 0; r < formInputs.length; r++) {
for (var e = 0; e < formInputs[r].length; e++) { for (var e = 0; e < formInputs[r].length; e++) {
if (widget.items[r][e].type == FormItemType.bool) { if (widget.items[r][e] is GeneratedFormSwitch) {
formInputs[r][e] = Row( formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(widget.items[r][e].label), Text(widget.items[r][e].label),
Switch( Switch(
value: values[widget.items[r][e].key] == 'true', value: values[widget.items[r][e].key],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[widget.items[r][e].key] = value ? 'true' : ''; values[widget.items[r][e].key] = value;
someValueChanged(); someValueChanged();
}); });
}) })
@ -171,9 +221,8 @@ class _GeneratedFormState extends State<GeneratedForm> {
if (rowInputs.key > 0) { if (rowInputs.key > 0) {
rows.add([ rows.add([
SizedBox( SizedBox(
height: widget.items[rowInputs.key][0].type == FormItemType.bool && height: widget.items[rowInputs.key][0] is GeneratedFormSwitch &&
widget.items[rowInputs.key - 1][0].type == widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch
FormItemType.string
? 25 ? 25
: 8, : 8,
) )

View File

@ -21,7 +21,7 @@ class GeneratedFormModal extends StatefulWidget {
} }
class _GeneratedFormModalState extends State<GeneratedFormModal> { class _GeneratedFormModalState extends State<GeneratedFormModal> {
Map<String, String> values = {}; Map<String, dynamic> values = {};
bool valid = false; bool valid = false;
@override @override

View File

@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:easy_localization/src/localization.dart'; import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.8.22'; const String currentVersion = '0.9.2';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -200,7 +200,7 @@ class _ObtainiumState extends State<Obtainium> {
currentReleaseTag, currentReleaseTag,
[], [],
0, 0,
{'includePrereleases': 'true'}, {'includePrereleases': true},
null, null,
false) false)
]); ]);

View File

@ -27,7 +27,7 @@ class _AddAppPageState extends State<AddAppPage> {
String userInput = ''; String userInput = '';
String searchQuery = ''; String searchQuery = '';
AppSource? pickedSource; AppSource? pickedSource;
Map<String, String> additionalSettings = {}; Map<String, dynamic> additionalSettings = {};
bool additionalSettingsValid = true; bool additionalSettingsValid = true;
@override @override
@ -66,9 +66,9 @@ class _AddAppPageState extends State<AddAppPage> {
}); });
var settingsProvider = context.read<SettingsProvider>(); var settingsProvider = context.read<SettingsProvider>();
() async { () async {
var userPickedTrackOnly = additionalSettings['trackOnly'] == 'true'; var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var userPickedNoVersionDetection = var userPickedNoVersionDetection =
additionalSettings['noVersionDetection'] == 'true'; additionalSettings['noVersionDetection'] == true;
var cont = true; var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog( await showDialog(
@ -113,7 +113,7 @@ class _AddAppPageState extends State<AddAppPage> {
} }
// Only download the APK here if you need to for the package ID // Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && if (sourceProvider.isTempId(app.id) &&
app.additionalSettings['trackOnly'] != 'true') { app.additionalSettings['trackOnly'] != true) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context); var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) { if (apkUrl == null) {
@ -128,7 +128,7 @@ class _AddAppPageState extends State<AddAppPage> {
if (appsProvider.apps.containsKey(app.id)) { if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError(tr('appAlreadyAdded')); throw ObtainiumError(tr('appAlreadyAdded'));
} }
if (app.additionalSettings['trackOnly'] == 'true') { if (app.additionalSettings['trackOnly'] == true) {
app.installedVersion = app.latestVersion; app.installedVersion = app.latestVersion;
} }
await appsProvider.saveApps([app]); await appsProvider.saveApps([app]);
@ -169,7 +169,7 @@ class _AddAppPageState extends State<AddAppPage> {
child: GeneratedForm( child: GeneratedForm(
items: [ items: [
[ [
GeneratedFormItem('appSourceURL', GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'), label: tr('appSourceURL'),
additionalValidators: [ additionalValidators: [
(value) { (value) {
@ -231,7 +231,8 @@ class _AddAppPageState extends State<AddAppPage> {
child: GeneratedForm( child: GeneratedForm(
items: [ items: [
[ [
GeneratedFormItem('searchSomeSources', GeneratedFormTextField(
'searchSomeSources',
label: tr('searchSomeSourcesLabel'), label: tr('searchSomeSourcesLabel'),
required: false), required: false),
] ]

View File

@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
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/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart'; import 'package:obtainium/main.dart';
@ -33,6 +34,7 @@ class _AppPageState extends State<AppPage> {
}); });
} }
var categories = settingsProvider.categories;
var sourceProvider = SourceProvider(); 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; var source = app != null ? sourceProvider.getSource(app.app.url) : null;
@ -40,7 +42,7 @@ class _AppPageState extends State<AppPage> {
prevApp = app; prevApp = app;
getUpdate(app.app.id); getUpdate(app.app.id);
} }
var trackOnly = app?.app.additionalSettings['trackOnly'] == 'true'; var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null, appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@ -148,7 +150,51 @@ class _AppPageState extends State<AppPage> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12), fontStyle: FontStyle.italic, fontSize: 12),
) ),
const SizedBox(
height: 32,
),
app?.app.category != null
? Chip(
label: Text(app!.app.category!),
backgroundColor:
Color(categories[app.app.category!] ?? 0x0),
onDeleted: () {
app.app.category = null;
appsProvider.saveApps([app.app]);
},
visualDensity: VisualDensity.compact,
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Pick a Category',
items: [
[
settingsProvider
.getCategoryFormItem()
]
]);
}).then((value) {
if (value != null && app != null) {
String? cat = (value['category']
?.isNotEmpty ??
false)
? value['category']
: null;
app.app.category = cat;
appsProvider.saveApps([app.app]);
}
});
},
child: Text(tr('categorize')))
])
], ],
)), )),
], ],
@ -227,7 +273,7 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
showDialog<Map<String, String>>( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var items = source var items = source
@ -255,7 +301,7 @@ class _AppPageState extends State<AppPage> {
values; values;
if (source.enforceTrackOnly) { if (source.enforceTrackOnly) {
changedApp.additionalSettings[ changedApp.additionalSettings[
'trackOnly'] = 'true'; 'trackOnly'] = true;
showError( showError(
tr('appsFromSourceAreTrackOnly'), tr('appsFromSourceAreTrackOnly'),
context); context);
@ -281,7 +327,7 @@ class _AppPageState extends State<AppPage> {
() async { () async {
if (app?.app.additionalSettings[ if (app?.app.additionalSettings[
'trackOnly'] != 'trackOnly'] !=
'true') { true) {
await settingsProvider await settingsProvider
.getInstallPermission(); .getInstallPermission();
} }

View File

@ -80,28 +80,31 @@ class AppsPageState extends State<AppsPage> {
!(filter!.includeNonInstalled)) { !(filter!.includeNonInstalled)) {
return false; return false;
} }
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { if (filter!.nameFilter.isNotEmpty || filter!.authorFilter.isNotEmpty) {
return true; List<String> nameTokens = filter!.nameFilter
} .split(' ')
List<String> nameTokens = filter!.nameFilter .where((element) => element.trim().isNotEmpty)
.split(' ') .toList();
.where((element) => element.trim().isNotEmpty) List<String> authorTokens = filter!.authorFilter
.toList(); .split(' ')
List<String> authorTokens = filter!.authorFilter .where((element) => element.trim().isNotEmpty)
.split(' ') .toList();
.where((element) => element.trim().isNotEmpty)
.toList();
for (var t in nameTokens) { for (var t in nameTokens) {
var name = app.installedInfo?.name ?? app.app.name; var name = app.installedInfo?.name ?? app.app.name;
if (!name.toLowerCase().contains(t.toLowerCase())) { if (!name.toLowerCase().contains(t.toLowerCase())) {
return false; return false;
}
}
for (var t in authorTokens) {
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
return false;
}
} }
} }
for (var t in authorTokens) { if (filter!.categoryFilter.isNotEmpty &&
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { filter!.categoryFilter != app.app.category) {
return false; return false;
}
} }
return true; return true;
}).toList(); }).toList();
@ -139,16 +142,14 @@ class AppsPageState extends State<AppsPage> {
List<String> trackOnlyUpdateIdsAllOrSelected = []; List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) {
'true') {
trackOnlyUpdateIdsAllOrSelected.add(id); trackOnlyUpdateIdsAllOrSelected.add(id);
return false; return false;
} }
return true; return true;
}).toList(); }).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) {
'true') {
trackOnlyUpdateIdsAllOrSelected.add(id); trackOnlyUpdateIdsAllOrSelected.add(id);
return false; return false;
} }
@ -225,96 +226,114 @@ class AppsPageState extends State<AppsPage> {
String? changesUrl = SourceProvider() String? changesUrl = SourceProvider()
.getSource(sortedApps[index].app.url) .getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(sortedApps[index].app.url); .changeLogPageFromStandardUrl(sortedApps[index].app.url);
return ListTile( return Container(
tileColor: sortedApps[index].app.pinned decoration: BoxDecoration(
? Colors.grey.withOpacity(0.1) border: Border.symmetric(
: Colors.transparent, vertical: BorderSide(
selectedTileColor: Theme.of(context) width: 3,
.colorScheme color: Color(settingsProvider.categories[
.primary sortedApps[index].app.category] ??
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), const Color.fromARGB(0, 0, 0, 0).value)))),
selected: selectedApps.contains(sortedApps[index].app), child: ListTile(
onLongPress: () { tileColor: sortedApps[index].app.pinned
toggleAppSelected(sortedApps[index].app); ? Colors.grey.withOpacity(0.1)
}, : Colors.transparent,
leading: sortedApps[index].installedInfo != null selectedTileColor: Theme.of(context)
? Image.memory( .colorScheme
sortedApps[index].installedInfo!.icon!, .primary
gaplessPlayback: true, .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
) selected: selectedApps.contains(sortedApps[index].app),
: null, onLongPress: () {
title: Text( toggleAppSelected(sortedApps[index].app);
sortedApps[index].installedInfo?.name ?? },
sortedApps[index].app.name, leading: sortedApps[index].installedInfo != null
style: TextStyle( ? Image.memory(
fontWeight: sortedApps[index].app.pinned sortedApps[index].installedInfo!.icon!,
? FontWeight.bold gaplessPlayback: true,
: FontWeight.normal), )
), : null,
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]), title: Text(
style: TextStyle( sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name,
style: TextStyle(
fontWeight: sortedApps[index].app.pinned fontWeight: sortedApps[index].app.pinned
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal)), : FontWeight.normal,
trailing: SingleChildScrollView( ),
reverse: true, ),
child: sortedApps[index].downloadProgress != null subtitle: Text(
? Text(tr('percentProgress', args: [ tr('byX', args: [sortedApps[index].app.author]),
sortedApps[index] style: TextStyle(
.downloadProgress fontWeight: sortedApps[index].app.pinned
?.toInt() ? FontWeight.bold
.toString() ?? : FontWeight.normal)),
'100' trailing: SingleChildScrollView(
])) reverse: true,
: (Column( child: sortedApps[index].downloadProgress != null
mainAxisAlignment: MainAxisAlignment.center, ? Text(tr('percentProgress', args: [
crossAxisAlignment: CrossAxisAlignment.end, sortedApps[index]
children: [ .downloadProgress
SizedBox( ?.toInt()
width: 100, .toString() ??
child: Text( '100'
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBrackets')}' : ''}', ]))
overflow: TextOverflow.fade, : (Column(
textAlign: TextAlign.end, mainAxisAlignment: MainAxisAlignment.center,
)), crossAxisAlignment: CrossAxisAlignment.end,
sortedApps[index].app.installedVersion != null && children: [
sortedApps[index].app.installedVersion != SizedBox(
sortedApps[index].app.latestVersion width: 100,
? GestureDetector( child: Text(
onTap: changesUrl == null '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBrackets')}' : ''}',
? null overflow: TextOverflow.fade,
: () { textAlign: TextAlign.end,
launchUrlString(changesUrl, )),
mode: LaunchMode sortedApps[index].app.installedVersion !=
.externalApplication); null &&
}, sortedApps[index]
child: appsProvider.areDownloadsRunning() .app
? Text(tr('pleaseWait')) .installedVersion !=
: Text( sortedApps[index]
'${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == 'true' ? ' ${tr('estimateInBracketsShort')}' : ''}', .app
style: TextStyle( .latestVersion
fontStyle: FontStyle.italic, ? GestureDetector(
decoration: changesUrl == null onTap: changesUrl == null
? TextDecoration.none ? null
: TextDecoration : () {
.underline), launchUrlString(changesUrl,
)) mode: LaunchMode
: const SizedBox(), .externalApplication);
], },
))), child: appsProvider
onTap: () { .areDownloadsRunning()
if (selectedApps.isNotEmpty) { ? Text(tr('pleaseWait'))
toggleAppSelected(sortedApps[index].app); : Text(
} else { '${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBracketsShort')}' : ''}',
Navigator.push( style: TextStyle(
context, fontStyle:
MaterialPageRoute( FontStyle.italic,
builder: (context) => decoration: changesUrl ==
AppPage(appId: sortedApps[index].app.id)), null
); ? TextDecoration.none
} : TextDecoration
}, .underline),
); ))
: const SizedBox(),
],
))),
onTap: () {
if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: sortedApps[index].app.id)),
);
}
},
));
}, childCount: sortedApps.length)) }, childCount: sortedApps.length))
])), ])),
persistentFooterButtons: [ persistentFooterButtons: [
@ -345,7 +364,7 @@ class AppsPageState extends State<AppsPage> {
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: () { onPressed: () {
showDialog<Map<String, String>?>( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
@ -379,40 +398,33 @@ class AppsPageState extends State<AppsPage> {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
List<GeneratedFormItem> formItems = []; List<GeneratedFormItem> formItems = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty) { if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormItem('updates', formItems.add(GeneratedFormSwitch('updates',
label: tr('updateX', args: [ label: tr('updateX', args: [
plural('apps', plural('apps',
existingUpdateIdsAllOrSelected.length) existingUpdateIdsAllOrSelected.length)
]), ]),
type: FormItemType.bool, defaultValue: true));
defaultValue: 'true'));
} }
if (newInstallIdsAllOrSelected.isNotEmpty) { if (newInstallIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormItem('installs', formItems.add(GeneratedFormSwitch('installs',
label: tr('installX', args: [ label: tr('installX', args: [
plural('apps', plural('apps',
newInstallIdsAllOrSelected.length) newInstallIdsAllOrSelected.length)
]), ]),
type: FormItemType.bool, defaultValue: existingUpdateIdsAllOrSelected
defaultValue: .isNotEmpty));
existingUpdateIdsAllOrSelected.isNotEmpty
? 'true'
: ''));
} }
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormItem('trackonlies', formItems.add(GeneratedFormSwitch('trackonlies',
label: tr('markXTrackOnlyAsUpdated', args: [ label: tr('markXTrackOnlyAsUpdated', args: [
plural('apps', plural('apps',
trackOnlyUpdateIdsAllOrSelected.length) trackOnlyUpdateIdsAllOrSelected.length)
]), ]),
type: FormItemType.bool,
defaultValue: existingUpdateIdsAllOrSelected defaultValue: existingUpdateIdsAllOrSelected
.isNotEmpty || .isNotEmpty ||
newInstallIdsAllOrSelected.isNotEmpty newInstallIdsAllOrSelected.isNotEmpty));
? 'true'
: ''));
} }
showDialog<Map<String, String>?>( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected var totalApps = existingUpdateIdsAllOrSelected
@ -432,11 +444,11 @@ class AppsPageState extends State<AppsPage> {
[formItems]); [formItems]);
} }
bool shouldInstallUpdates = bool shouldInstallUpdates =
values['updates'] == 'true'; values['updates'] == true;
bool shouldInstallNew = bool shouldInstallNew =
values['installs'] == 'true'; values['installs'] == true;
bool shouldMarkTrackOnlies = bool shouldMarkTrackOnlies =
values['trackonlies'] == 'true'; values['trackonlies'] == true;
(() async { (() async {
if (shouldInstallNew || if (shouldInstallNew ||
shouldInstallUpdates) { shouldInstallUpdates) {
@ -678,7 +690,7 @@ class AppsPageState extends State<AppsPage> {
: FontWeight.bold), : FontWeight.bold),
), ),
onPressed: () { onPressed: () {
showDialog<Map<String, String>?>( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var vals = filter == null var vals = filter == null
@ -688,26 +700,28 @@ class AppsPageState extends State<AppsPage> {
title: tr('filterApps'), title: tr('filterApps'),
items: [ items: [
[ [
GeneratedFormItem('appName', GeneratedFormTextField('appName',
label: tr('appName'), label: tr('appName'),
required: false, required: false,
defaultValue: vals['appName']), defaultValue: vals['appName']),
GeneratedFormItem('author', GeneratedFormTextField('author',
label: tr('author'), label: tr('author'),
required: false, required: false,
defaultValue: vals['author']) defaultValue: vals['author'])
], ],
[ [
GeneratedFormItem('upToDateApps', GeneratedFormSwitch('upToDateApps',
label: tr('upToDateApps'), label: tr('upToDateApps'),
type: FormItemType.bool,
defaultValue: vals['upToDateApps']) defaultValue: vals['upToDateApps'])
], ],
[ [
GeneratedFormItem('nonInstalledApps', GeneratedFormSwitch('nonInstalledApps',
label: tr('nonInstalledApps'), label: tr('nonInstalledApps'),
type: FormItemType.bool,
defaultValue: vals['nonInstalledApps']) defaultValue: vals['nonInstalledApps'])
],
[
settingsProvider.getCategoryFormItem(
initCategory: vals['category'] ?? '')
] ]
]); ]);
}).then((values) { }).then((values) {
@ -734,32 +748,37 @@ class AppsFilter {
late String authorFilter; late String authorFilter;
late bool includeUptodate; late bool includeUptodate;
late bool includeNonInstalled; late bool includeNonInstalled;
late String categoryFilter;
AppsFilter( AppsFilter(
{this.nameFilter = '', {this.nameFilter = '',
this.authorFilter = '', this.authorFilter = '',
this.includeUptodate = true, this.includeUptodate = true,
this.includeNonInstalled = true}); this.includeNonInstalled = true,
this.categoryFilter = ''});
Map<String, String> toValuesMap() { Map<String, dynamic> toValuesMap() {
return { return {
'appName': nameFilter, 'appName': nameFilter,
'author': authorFilter, 'author': authorFilter,
'upToDateApps': includeUptodate ? 'true' : '', 'upToDateApps': includeUptodate,
'nonInstalledApps': includeNonInstalled ? 'true' : '' 'nonInstalledApps': includeNonInstalled,
'category': categoryFilter
}; };
} }
AppsFilter.fromValuesMap(Map<String, String> values) { AppsFilter.fromValuesMap(Map<String, dynamic> values) {
nameFilter = values['appName']!; nameFilter = values['appName']!;
authorFilter = values['author']!; authorFilter = values['author']!;
includeUptodate = values['upToDateApps'] == 'true'; includeUptodate = values['upToDateApps'];
includeNonInstalled = values['nonInstalledApps'] == 'true'; includeNonInstalled = values['nonInstalledApps'];
categoryFilter = values['category']!;
} }
bool isIdenticalTo(AppsFilter other) => bool isIdenticalTo(AppsFilter other) =>
authorFilter.trim() == other.authorFilter.trim() && authorFilter.trim() == other.authorFilter.trim() &&
nameFilter.trim() == other.nameFilter.trim() && nameFilter.trim() == other.nameFilter.trim() &&
includeUptodate == other.includeUptodate && includeUptodate == other.includeUptodate &&
includeNonInstalled == other.includeNonInstalled; includeNonInstalled == other.includeNonInstalled &&
categoryFilter.trim() == other.categoryFilter.trim();
} }

View File

@ -138,18 +138,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
showDialog( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: tr('importFromURLList'), title: tr('importFromURLList'),
items: [ items: [
[ [
GeneratedFormItem('appURLList', GeneratedFormTextField(
'appURLList',
label: tr('appURLList'), label: tr('appURLList'),
max: 7, max: 7,
additionalValidators: [ additionalValidators: [
(String? value) { (dynamic value) {
if (value != null && if (value != null &&
value.isNotEmpty) { value.isNotEmpty) {
var lines = value var lines = value
@ -176,7 +177,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
var urls = var urls =
(values[0] as String).split('\n'); (values['appURLList'] as String)
.split('\n');
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
@ -224,7 +226,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
: () { : () {
() async { () async {
var values = await showDialog< var values = await showDialog<
List<String>>( Map<String,
dynamic>?>(
context: context, context: context,
builder: builder:
(BuildContext ctx) { (BuildContext ctx) {
@ -235,7 +238,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
]), ]),
items: [ items: [
[ [
GeneratedFormItem( GeneratedFormTextField(
'searchQuery', 'searchQuery',
label: tr( label: tr(
'searchQuery')) 'searchQuery'))
@ -244,13 +247,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
); );
}); });
if (values != null && if (values != null &&
values[0].isNotEmpty) { (values['searchQuery']
as String?)
?.isNotEmpty ==
true) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
var urlsWithDescriptions = var urlsWithDescriptions =
await source await source.search(
.search(values[0]); values['searchQuery']
as String);
if (urlsWithDescriptions if (urlsWithDescriptions
.isNotEmpty) { .isNotEmpty) {
var selectedUrls = var selectedUrls =
@ -345,7 +352,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
.requiredArgs .requiredArgs
.map( .map(
(e) => [ (e) => [
GeneratedFormItem(e, GeneratedFormTextField(e,
label: e) label: e)
]) ])
.toList(), .toList(),

View File

@ -1,8 +1,12 @@
import 'dart:math';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.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.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/logs_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:obtainium/providers/source_provider.dart';
@ -17,11 +21,27 @@ class SettingsPage extends StatefulWidget {
State<SettingsPage> createState() => _SettingsPageState(); State<SettingsPage> createState() => _SettingsPageState();
} }
// Generates a random light color
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
Color generateRandomLightColor() {
// Create a random number generator
final Random random = Random();
// Generate random hue, saturation, and value values
final double hue = random.nextDouble() * 360;
final double saturation = 0.5 + random.nextDouble() * 0.5;
final double value = 0.9 + random.nextDouble() * 0.1;
// Create a HSV color with the random values
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
}
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
@ -138,9 +158,10 @@ class _SettingsPageState extends State<SettingsPage> {
var sourceSpecificFields = sourceProvider.sources.map((e) { var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) { if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm( return GeneratedForm(
items: e.additionalSourceSpecificSettingFormItems items: e.additionalSourceSpecificSettingFormItems.map((e) {
.map((e) => [e]) e.defaultValue = settingsProvider.getSettingString(e.key);
.toList(), return [e];
}).toList(),
onValueChanges: (values, valid, isBuilding) { onValueChanges: (values, valid, isBuilding) {
if (valid) { if (valid) {
values.forEach((key, value) { values.forEach((key, value) {
@ -157,6 +178,8 @@ class _SettingsPageState extends State<SettingsPage> {
height: 16, height: 16,
); );
var categories = settingsProvider.categories;
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -232,6 +255,94 @@ class _SettingsPageState extends State<SettingsPage> {
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
...sourceSpecificFields, ...sourceSpecificFields,
const Divider(
height: 48,
),
Text(
tr('categories'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
height16,
Wrap(
children: [
...categories.entries.toList().map((e) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4),
child: Chip(
label: Text(e.key),
backgroundColor: Color(e.value),
visualDensity: VisualDensity.compact,
onDeleted: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr(
'deleteCategoryQuestion'),
message: tr(
'categoryDeleteWarning',
args: [e.key]),
items: []);
}).then((value) {
if (value != null) {
setState(() {
categories.remove(e.key);
settingsProvider.categories =
categories;
});
appsProvider.saveApps(appsProvider
.apps.values
.where((element) =>
element.app.category ==
e.key)
.map((e) {
var a = e.app;
a.category = null;
return a;
}).toList());
}
});
},
));
}),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4),
child: IconButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('addCategory'),
items: [
[
GeneratedFormTextField(
'label',
label: tr('label'))
]
]);
}).then((value) {
String? label = value?['label'];
if (label != null) {
setState(() {
categories[label] =
generateRandomLightColor()
.value;
settingsProvider.categories =
categories;
});
}
});
},
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
))
],
)
], ],
))), ))),
SliverToBoxAdapter( SliverToBoxAdapter(

View File

@ -313,7 +313,7 @@ class AppsProvider with ChangeNotifier {
throw ObtainiumError(tr('appNotFound')); throw ObtainiumError(tr('appNotFound'));
} }
String? apkUrl; String? apkUrl;
var trackOnly = apps[id]!.app.additionalSettings['trackOnly'] == 'true'; var trackOnly = apps[id]!.app.additionalSettings['trackOnly'] == true;
if (!trackOnly) { if (!trackOnly) {
apkUrl = await confirmApkUrl(apps[id]!.app, context); apkUrl = await confirmApkUrl(apps[id]!.app, context);
} }
@ -452,9 +452,9 @@ class AppsProvider with ChangeNotifier {
// Don't save changes, just return the object if changes were made (else null) // Don't save changes, just return the object if changes were made (else null)
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
var modded = false; var modded = false;
var trackOnly = app.additionalSettings['trackOnly'] == 'true'; var trackOnly = app.additionalSettings['trackOnly'] == true;
var noVersionDetection = var noVersionDetection =
app.additionalSettings['noVersionDetection'] == 'true'; app.additionalSettings['noVersionDetection'] == true;
if (installedInfo == null && app.installedVersion != null && !trackOnly) { if (installedInfo == null && app.installedVersion != null && !trackOnly) {
app.installedVersion = null; app.installedVersion = null;
modded = true; modded = true;

View File

@ -1,9 +1,12 @@
// Exposes functions used to save/load app settings // Exposes functions used to save/load app settings
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -144,4 +147,20 @@ class SettingsProvider with ChangeNotifier {
prefs?.setString(settingId, value); prefs?.setString(settingId, value);
notifyListeners(); notifyListeners();
} }
Map<String, int> get categories =>
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
set categories(Map<String, int> cats) {
prefs?.setString('categories', jsonEncode(cats));
}
getCategoryFormItem({String initCategory = ''}) => GeneratedFormDropdown(
'category',
label: tr('category'),
[
MapEntry('', tr('noCategory')),
...categories.entries.map((e) => MapEntry(e.key, e.key)).toList()
],
defaultValue: initCategory);
} }

View File

@ -19,6 +19,7 @@ import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart';
import 'package:obtainium/providers/settings_provider.dart';
class AppNames { class AppNames {
late String author; late String author;
@ -44,9 +45,10 @@ class App {
late String latestVersion; late String latestVersion;
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex; late int preferredApkIndex;
late Map<String, String> additionalSettings; late Map<String, dynamic> additionalSettings;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false; bool pinned = false;
String? category;
App( App(
this.id, this.id,
this.url, this.url,
@ -58,7 +60,8 @@ class App {
this.preferredApkIndex, this.preferredApkIndex,
this.additionalSettings, this.additionalSettings,
this.lastUpdateCheck, this.lastUpdateCheck,
this.pinned); this.pinned,
{this.category});
@override @override
String toString() { String toString() {
@ -69,24 +72,36 @@ class App {
var source = SourceProvider().getSource(json['url']); var source = SourceProvider().getSource(json['url']);
var formItems = source.combinedAppSpecificSettingFormItems var formItems = source.combinedAppSpecificSettingFormItems
.reduce((value, element) => [...value, ...element]); .reduce((value, element) => [...value, ...element]);
Map<String, String> additionalSettings = Map<String, dynamic> additionalSettings =
getDefaultValuesFromFormItems([formItems]); getDefaultValuesFromFormItems([formItems]);
if (json['additionalSettings'] != null) { if (json['additionalSettings'] != null) {
additionalSettings.addEntries( additionalSettings.addEntries(
Map<String, String>.from(jsonDecode(json['additionalSettings'])) Map<String, dynamic>.from(jsonDecode(json['additionalSettings']))
.entries); .entries);
} }
// If needed, migrate old-style additionalData to new-style additionalSettings // If needed, migrate old-style additionalData to newer-style additionalSettings (V1)
if (json['additionalData'] != null) { if (json['additionalData'] != null) {
List<String> temp = List<String>.from(jsonDecode(json['additionalData'])); List<String> temp = List<String>.from(jsonDecode(json['additionalData']));
temp.asMap().forEach((i, value) { temp.asMap().forEach((i, value) {
if (i < formItems.length) { if (i < formItems.length) {
additionalSettings[formItems[i].key] = value; if (formItems[i] is GeneratedFormSwitch) {
additionalSettings[formItems[i].key] = value == 'true';
} else {
additionalSettings[formItems[i].key] = value;
}
} }
}); });
additionalSettings['trackOnly'] = (json['trackOnly'] ?? false).toString(); additionalSettings['trackOnly'] =
json['trackOnly'] == 'true' || json['trackOnly'] == true;
additionalSettings['noVersionDetection'] = additionalSettings['noVersionDetection'] =
(json['noVersionDetection'] ?? false).toString(); json['noVersionDetection'] == 'true' || json['trackOnly'] == true;
}
// Ensure additionalSettings are correctly typed
for (var item in formItems) {
if (additionalSettings[item.key] != null) {
additionalSettings[item.key] =
item.ensureType(additionalSettings[item.key]);
}
} }
return App( return App(
json['id'] as String, json['id'] as String,
@ -107,7 +122,8 @@ class App {
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false); json['pinned'] ?? false,
category: json['category']);
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -121,7 +137,8 @@ class App {
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalSettings': jsonEncode(additionalSettings), 'additionalSettings': jsonEncode(additionalSettings),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned 'pinned': pinned,
'category': category
}; };
} }
@ -155,7 +172,7 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
Map<String, String> getDefaultValuesFromFormItems( Map<String, dynamic> getDefaultValuesFromFormItems(
List<List<GeneratedFormItem>> items) { List<List<GeneratedFormItem>> items) {
return Map.fromEntries(items return Map.fromEntries(items
.map((row) => row.map((el) => MapEntry(el.key, el.defaultValue ?? ''))) .map((row) => row.map((el) => MapEntry(el.key, el.defaultValue ?? '')))
@ -176,7 +193,7 @@ class AppSource {
} }
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, String> additionalSettings) { String standardUrl, Map<String, dynamic> additionalSettings) {
throw NotImplementedError(); throw NotImplementedError();
} }
@ -188,16 +205,12 @@ class AppSource {
final List<List<GeneratedFormItem>> final List<List<GeneratedFormItem>>
additionalAppSpecificSourceAgnosticSettingFormItems = [ additionalAppSpecificSourceAgnosticSettingFormItems = [
[ [
GeneratedFormItem( GeneratedFormSwitch(
'trackOnly', 'trackOnly',
label: tr('trackOnly'), label: tr('trackOnly'),
type: FormItemType.bool,
) )
], ],
[ [GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))]
GeneratedFormItem('noVersionDetection',
label: tr('noVersionDetection'), type: FormItemType.bool)
]
]; ];
// Previous 2 variables combined into one at runtime for convenient usage // Previous 2 variables combined into one at runtime for convenient usage
@ -225,7 +238,7 @@ class AppSource {
} }
String? tryInferringAppId(String standardUrl, String? tryInferringAppId(String standardUrl,
{Map<String, String> additionalSettings = const {}}) { {Map<String, dynamic> additionalSettings = const {}}) {
return null; return null;
} }
} }
@ -288,7 +301,7 @@ class SourceProvider {
bool ifRequiredAppSpecificSettingsExist(AppSource source) { bool ifRequiredAppSpecificSettingsExist(AppSource source) {
for (var row in source.combinedAppSpecificSettingFormItems) { for (var row in source.combinedAppSpecificSettingFormItems) {
for (var element in row) { for (var element in row) {
if (element.required && element.opts == null) { if (element is GeneratedFormTextField && element.required) {
return true; return true;
} }
} }
@ -314,17 +327,17 @@ class SourceProvider {
} }
Future<App> getApp( Future<App> getApp(
AppSource source, String url, Map<String, String> additionalSettings, AppSource source, String url, Map<String, dynamic> additionalSettings,
{App? currentApp, {App? currentApp,
bool trackOnlyOverride = false, bool trackOnlyOverride = false,
noVersionDetectionOverride = false}) async { noVersionDetectionOverride = false}) async {
if (trackOnlyOverride) { if (trackOnlyOverride) {
additionalSettings['trackOnly'] = 'true'; additionalSettings['trackOnly'] = true;
} }
if (noVersionDetectionOverride) { if (noVersionDetectionOverride) {
additionalSettings['noVersionDetection'] = 'true'; additionalSettings['noVersionDetection'] = true;
} }
var trackOnly = currentApp?.additionalSettings['trackOnly'] == 'true'; var trackOnly = currentApp?.additionalSettings['trackOnly'] == true;
String standardUrl = source.standardizeURL(preStandardizeUrl(url)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
APKDetails apk = APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalSettings); await source.getLatestAPKDetails(standardUrl, additionalSettings);
@ -350,7 +363,8 @@ class SourceProvider {
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalSettings, additionalSettings,
DateTime.now(), DateTime.now(),
currentApp?.pinned ?? false); currentApp?.pinned ?? false,
category: currentApp?.category);
} }
// Returns errors in [results, errors] instead of throwing them // Returns errors in [results, errors] instead of throwing them

View File

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