mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-29 20:43:28 +01:00 
			
		
		
		
	Compare commits
	
		
			30 Commits
		
	
	
		
			v0.9.5-bet
			...
			v0.9.11-be
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 3b449d0982 | ||
|  | 1863f55372 | ||
|  | 0c4b8ac79d | ||
|  | e287087753 | ||
|  | 82bcc46d42 | ||
|  | 1f26188ec6 | ||
|  | 794c3e1a81 | ||
|  | 16369b4adf | ||
|  | 8f16f745be | ||
|  | 8ddeb3d776 | ||
|  | 21cf9c98d9 | ||
|  | 358f910d19 | ||
|  | 7a3d74bd05 | ||
|  | 6f27f64699 | ||
|  | 3341fecb68 | ||
|  | d3bce63ca4 | ||
|  | 8aa8b6b698 | ||
|  | 3d6c9bbf98 | ||
|  | 7af0a8628c | ||
|  | 4573ce6bcf | ||
|  | e29d38fa32 | ||
|  | dc82431235 | ||
|  | 424b0028bf | ||
|  | 46fba9e0a4 | ||
|  | b40be7569b | ||
|  | a173be11eb | ||
|  | 0c97b25d99 | ||
|  | f836fd20d8 | ||
|  | 2f6917592d | ||
|  | b864fef3ad | 
| @@ -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 | 
| @@ -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", | ||||
| @@ -198,7 +198,7 @@ | ||||
|     "downloadingX": "{} letöltés", | ||||
|     "downloadNotifDescription": "Értesíti a felhasználót az app letöltésének előrehaladásáról", | ||||
|     "noAPKFound": "Nem található APK", | ||||
|     "noVersionDetection": "Nincs verzióérzékelés", | ||||
|     "noVersionDetection": "Nincs verzió érzékelés", | ||||
|     "categorize": "Kategorizálás", | ||||
|     "categories": "Kategóriák", | ||||
|     "category": "Kategória", | ||||
| @@ -207,6 +207,9 @@ | ||||
|     "categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.", | ||||
|     "addCategory": "Új kategória", | ||||
|     "label": "Címke", | ||||
|     "language": "Language", | ||||
|     "storagePermissionDenied": "Storage permission denied", | ||||
|     "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", | ||||
|     "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" | ||||
|   | ||||
| @@ -16,8 +16,8 @@ | ||||
|     "startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background", | ||||
|     "bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background", | ||||
|     "firstRun": "Questo è il primo avvio di sempre di Obtainium", | ||||
|     "settingUpdateCheckIntervalTo": "Imposta l'intervallo di aggiornamento a {}", | ||||
|     "githubPATLabel": "GitHub Personal Access Token (aumenta il limite di traffico)", | ||||
|     "settingUpdateCheckIntervalTo": "Fissato intervallo di aggiornamento a {}", | ||||
|     "githubPATLabel": "GitHub Personal Access Token (diminuisce limite di traffico)", | ||||
|     "githubPATHint": "PAT deve seguire questo formato: username:token", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "Informazioni su GitHub PAT", | ||||
| @@ -31,18 +31,18 @@ | ||||
|     "requiredInBrackets": "(richiesto)", | ||||
|     "dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE", | ||||
|     "colour": "Colore", | ||||
|     "githubStarredRepos": "i repository stellati da GitHub", | ||||
|     "githubStarredRepos": "repository stellati da GitHub", | ||||
|     "uname": "Username", | ||||
|     "wrongArgNum": "Numero di argomenti forniti errato", | ||||
|     "xIsTrackOnly": "{} è Solo-Monitoraggio", | ||||
|     "xIsTrackOnly": "{} è in modalità Solo-Monitoraggio", | ||||
|     "source": "Fonte", | ||||
|     "app": "App", | ||||
|     "appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.", | ||||
|     "youPickedTrackOnly": "Hai selezionato l'opzione 'Solo-Monitoraggio'.", | ||||
|     "youPickedTrackOnly": "È stata selezionata l'opzione 'Solo-Monitoraggio'.", | ||||
|     "trackOnlyAppDescription": "L'App sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.", | ||||
|     "cancelled": "Annullato", | ||||
|     "appAlreadyAdded": "App già aggiunta", | ||||
|     "alreadyUpToDateQuestion": "App già aggiornata?", | ||||
|     "alreadyUpToDateQuestion": "L'App è già aggiornata?", | ||||
|     "addApp": "Aggiungi App", | ||||
|     "appSourceURL": "URL della fonte dell'App", | ||||
|     "error": "Errore", | ||||
| @@ -60,20 +60,20 @@ | ||||
|     "percentProgress": "Progresso: {}%", | ||||
|     "pleaseWait": "Attendere prego", | ||||
|     "updateAvailable": "Aggiornamento disponibile", | ||||
|     "estimateInBracketsShort": "(Prev.)", | ||||
|     "estimateInBracketsShort": "(prev.)", | ||||
|     "notInstalled": "Non installato", | ||||
|     "estimateInBrackets": "(Previsto)", | ||||
|     "estimateInBrackets": "(previsto)", | ||||
|     "selectAll": "Seleziona tutto", | ||||
|     "deselectN": "Deseleziona {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} sarà rimosso da Obtainium ma resterà installato sul dispositivo.", | ||||
|     "xWillBeRemovedButRemainInstalled": "Verà effettuata la rimozione di {}, ma non la disinstallazione.", | ||||
|     "removeSelectedAppsQuestion": "Rimuovere le App selezionate?", | ||||
|     "removeSelectedApps": "Rimuovi le App selezionate", | ||||
|     "updateX": "Aggiorna {}", | ||||
|     "installX": "Installa {}", | ||||
|     "markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornato", | ||||
|     "changeX": "modifica {}", | ||||
|     "installUpdateApps": "Installa/Aggiorna le App", | ||||
|     "installUpdateSelectedApps": "Installa/Aggiornale le App selezionate", | ||||
|     "changeX": "Modifica {}", | ||||
|     "installUpdateApps": "Installa/Aggiorna App", | ||||
|     "installUpdateSelectedApps": "Installa/Aggiorna le App selezionate", | ||||
|     "onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).", | ||||
|     "markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?", | ||||
|     "no": "No", | ||||
| @@ -95,7 +95,7 @@ | ||||
|     "author": "Autore", | ||||
|     "upToDateApps": "App aggiornate", | ||||
|     "nonInstalledApps": "App non installate", | ||||
|     "importExport": "Importa/Esporta", | ||||
|     "importExport": "Importa - Esporta", | ||||
|     "settings": "Impostazioni", | ||||
|     "exportedTo": "Esportato in {}", | ||||
|     "obtainiumExport": "Esporta da Obtainium", | ||||
| @@ -134,7 +134,7 @@ | ||||
|     "neverManualOnly": "Mai - Solo manuale", | ||||
|     "appearance": "Aspetto", | ||||
|     "showWebInAppView": "Mostra pagina web dell'App se selezionata", | ||||
|     "pinUpdates": "Fissa in alto gli aggiornamenti disponibili", | ||||
|     "pinUpdates": "Fissa aggiornamenti disponibili in alto", | ||||
|     "updates": "Aggiornamenti", | ||||
|     "sourceSpecific": "Specifiche per la fonte", | ||||
|     "appSource": "Sorgente dell'App", | ||||
| @@ -146,21 +146,21 @@ | ||||
|     "obtainiumExportHyphenatedLowercase": "esportazione-obtainium", | ||||
|     "pickAnAPK": "Seleziona un APK", | ||||
|     "appHasMoreThanOnePackage": "{} offre più di un pacchetto:", | ||||
|     "deviceSupportsXArch": "Il tuo dispositivo supporta l'architettura {} della CPU.", | ||||
|     "deviceSupportsFollowingArchs": "Il tuo dispositivo supporta le seguenti architetture della CPU:", | ||||
|     "deviceSupportsXArch": "Il dispositivo in uso supporta l'architettura {} della CPU.", | ||||
|     "deviceSupportsFollowingArchs": "Il dispositivo in uso supporta le seguenti architetture della CPU:", | ||||
|     "warning": "Attenzione", | ||||
|     "sourceIsXButPackageFromYPrompt": "L'origine dell'App è '{}' ma il pacchetto della release proviene da '{}'. Continuare?", | ||||
|     "updatesAvailable": "Aggiornamenti disponibili", | ||||
|     "updatesAvailableNotifDescription": "Avvisa l'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium", | ||||
|     "updatesAvailableNotifDescription": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium", | ||||
|     "noNewUpdates": "Nessun nuovo aggiornamento.", | ||||
|     "xHasAnUpdate": "{} è stato aggiornato.", | ||||
|     "xHasAnUpdate": "Aggiornamento disponibile per {}", | ||||
|     "appsUpdated": "App aggiornate", | ||||
|     "appsUpdatedNotifDescription": "Avvisa l'utente che una o più App sono state aggiornate in background", | ||||
|     "appsUpdatedNotifDescription": "Notifica all'utente che una o più App sono state aggiornate in background", | ||||
|     "xWasUpdatedToY": "{} è stato aggiornato a {}.", | ||||
|     "errorCheckingUpdates": "Controllo degli errori per gli aggiornamenti", | ||||
|     "errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in background fallisce", | ||||
|     "appsRemoved": "App rimosse", | ||||
|     "appsRemovedNotifDescription": "Avvisa l'utente che una o più App sono state rimosse a causa di errori durante il caricamento", | ||||
|     "appsRemovedNotifDescription": "Notifica all'utente che una o più App sono state rimosse a causa di errori durante il caricamento", | ||||
|     "xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}", | ||||
|     "completeAppInstallation": "Completa l'installazione dell'App", | ||||
|     "obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App", | ||||
| @@ -178,7 +178,7 @@ | ||||
|     "installedVersionX": "Versione installata: {}", | ||||
|     "lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}", | ||||
|     "remove": "Rimuovi", | ||||
|     "removeAppQuestion": "Rimuovere App?", | ||||
|     "removeAppQuestion": "Rimuovere l'App?", | ||||
|     "yesMarkUpdated": "Sì, contrassegna come aggiornato", | ||||
|     "fdroid": "F-Droid", | ||||
|     "appIdOrName": "ID o nome dell'App", | ||||
| @@ -188,38 +188,40 @@ | ||||
|     "steam": "Steam", | ||||
|     "steamMobile": "Steam Mobile", | ||||
|     "steamChat": "Steam Chat", | ||||
|     "install": "Install", | ||||
|     "markInstalled": "Mark Installed", | ||||
|     "update": "Update", | ||||
|     "markUpdated": "Mark Updated", | ||||
|     "additionalOptions": "Additional Options", | ||||
|     "disableVersionDetection": "Disable Version Detection", | ||||
|     "noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.", | ||||
|     "downloadingX": "Downloading {}", | ||||
|     "downloadNotifDescription": "Notifies the user of the progress in downloading an App", | ||||
|     "noAPKFound": "No APK found", | ||||
|     "noVersionDetection": "No version detection", | ||||
|     "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", | ||||
|     "install": "Installa", | ||||
|     "markInstalled": "Contrassegna come installato", | ||||
|     "update": "Aggiorna", | ||||
|     "markUpdated": "Contrassegna come aggiornato", | ||||
|     "additionalOptions": "Opzioni aggiuntive", | ||||
|     "disableVersionDetection": "Disattiva il rilevamento della versione", | ||||
|     "noVersionDetectionExplanation": "Questa opzione dovrebbe essere usata solo per le App la cui versione non viene rilevata correttamente.", | ||||
|     "downloadingX": "Scaricamento di {} in corso", | ||||
|     "downloadNotifDescription": "Notifica all'utente lo stato di avanzamento del download di un'App", | ||||
|     "noAPKFound": "Nessun APK trovato", | ||||
|     "noVersionDetection": "Disattiva rilevamento di versione", | ||||
|     "categorize": "Aggiungi a categoria", | ||||
|     "categories": "Categorie", | ||||
|     "category": "Categoria", | ||||
|     "noCategory": "Nessuna categoria", | ||||
|     "noCategories": "Nessuna categoria", | ||||
|     "deleteCategoriesQuestion": "Eliminare le categorie?", | ||||
|     "categoryDeleteWarning": "Tutte le App nelle categorie eliminate saranno impostate come non categorizzate.", | ||||
|     "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" | ||||
|     }, | ||||
|     "bgUpdateGotErrorRetryInMinutes": { | ||||
|         "one": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuto", | ||||
|         "other": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuti" | ||||
|         "one": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuto", | ||||
|         "other": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuti" | ||||
|     }, | ||||
|     "bgCheckFoundUpdatesWillNotifyIfNeeded": { | ||||
|         "one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - avviserà l'utento se necessario", | ||||
|         "other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - avviserà l'utento se necessario" | ||||
|         "one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - notificherà l'utente se necessario", | ||||
|         "other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - notificherà l'utente se necessario" | ||||
|     }, | ||||
|     "apps": { | ||||
|         "one": "{} App", | ||||
| @@ -253,4 +255,4 @@ | ||||
|         "one": "{} e un'altra App sono state aggiornate.", | ||||
|         "other": "{} e altre {} App sono state aggiornate." | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -209,6 +209,8 @@ | ||||
|     "addCategory": "カテゴリを追加", | ||||
|     "label": "ラベル", | ||||
|     "language": "言語", | ||||
|     "storagePermissionDenied": "Storage permission denied", | ||||
|     "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", | ||||
|     "tooManyRequestsTryAgainInMinutes": { | ||||
|         "one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください", | ||||
|         "other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください" | ||||
|   | ||||
| @@ -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": "请求过多 (API 限制) - 在 {} 分钟后重试", | ||||
|         "other": "请求过多 (API 限制) - 在 {} 分钟后重试" | ||||
|   | ||||
| @@ -46,7 +46,7 @@ class APKMirror extends AppSource { | ||||
|       } | ||||
|       return APKDetails(version, [], getAppNames(standardUrl)); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -54,7 +54,7 @@ class FDroid extends AppSource { | ||||
|       return APKDetails(latestVersion, apkUrls, | ||||
|           AppNames(name, Uri.parse(standardUrl).pathSegments.last)); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -80,7 +80,7 @@ class FDroidRepo extends AppSource { | ||||
|           .toList(); | ||||
|       return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName)); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -59,7 +59,7 @@ class GitLab extends AppSource { | ||||
|       } | ||||
|       return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl)); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -43,7 +43,7 @@ class Mullvad extends AppSource { | ||||
|           ['https://mullvad.net/download/app/apk/latest'], | ||||
|           AppNames(name, 'Mullvad-VPN')); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -33,7 +33,7 @@ class Signal extends AppSource { | ||||
|       } | ||||
|       return APKDetails(version, apkUrls, AppNames(name, 'Signal')); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -57,7 +57,7 @@ class SourceForge extends AppSource { | ||||
|           AppNames( | ||||
|               name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1))); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -54,7 +54,7 @@ class SteamMobile extends AppSource { | ||||
|       var apkUrls = [links[0]]; | ||||
|       return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!)); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -91,6 +91,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 +101,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, | ||||
| @@ -140,11 +142,11 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|     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); | ||||
|           var fieldState = | ||||
|               (formInputs[r][i].key as GlobalKey<FormFieldState>).currentState; | ||||
|           if (fieldState != null) { | ||||
|             valid = valid && fieldState.isValid; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| @@ -152,7 +154,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|   } | ||||
|  | ||||
|   // Generates a random light color | ||||
| // Courtesy of ChatGPT 😭 (with a bugfix 🥳) | ||||
|   // Courtesy of ChatGPT 😭 (with a bugfix 🥳) | ||||
|   Color generateRandomLightColor() { | ||||
|     // Create a random number generator | ||||
|     final Random random = Random(); | ||||
| @@ -259,157 +261,185 @@ 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, | ||||
|             crossAxisAlignment: WrapCrossAlignment.center, | ||||
|             children: [ | ||||
|               (values[widget.items[r][e].key] | ||||
|                               as Map<String, MapEntry<int, bool>>?) | ||||
|                           ?.isEmpty == | ||||
|                       true | ||||
|                   ? Text( | ||||
|                       (widget.items[r][e] as GeneratedFormTagInput) | ||||
|                           .emptyMessage, | ||||
|                       style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                     ) | ||||
|                   : const SizedBox.shrink(), | ||||
|               ...(values[widget.items[r][e].key] | ||||
|                           as Map<String, MapEntry<int, bool>>?) | ||||
|                       ?.entries | ||||
|                       .map((e2) { | ||||
|                     return Padding( | ||||
|                         padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                         child: ChoiceChip( | ||||
|                           label: Text(e2.key), | ||||
|                           backgroundColor: Color(e2.value.key).withAlpha(50), | ||||
|                           selectedColor: Color(e2.value.key), | ||||
|                           visualDensity: VisualDensity.compact, | ||||
|                           selected: e2.value.value, | ||||
|                           onSelected: (value) { | ||||
|                             setState(() { | ||||
|                               (values[widget.items[r][e].key] as Map<String, | ||||
|                                       MapEntry<int, bool>>)[e2.key] = | ||||
|                                   MapEntry( | ||||
|                                       (values[widget.items[r][e].key] as Map< | ||||
|                                               String, | ||||
|                                               MapEntry<int, bool>>)[e2.key]! | ||||
|                                           .key, | ||||
|                                       value); | ||||
|                               if ((widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                       .singleSelect && | ||||
|                                   value == true) { | ||||
|                                 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( | ||||
|           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] | ||||
|                                 as Map<String, MapEntry<int, bool>>?) | ||||
|                             ?.isEmpty == | ||||
|                         true | ||||
|                     ? Text( | ||||
|                         (widget.items[r][e] as GeneratedFormTagInput) | ||||
|                             .emptyMessage, | ||||
|                       ) | ||||
|                     : const SizedBox.shrink(), | ||||
|                 ...(values[widget.items[r][e].key] | ||||
|                             as Map<String, MapEntry<int, bool>>?) | ||||
|                         ?.entries | ||||
|                         .map((e2) { | ||||
|                       return Padding( | ||||
|                           padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                           child: ChoiceChip( | ||||
|                             label: Text(e2.key), | ||||
|                             backgroundColor: Color(e2.value.key).withAlpha(50), | ||||
|                             selectedColor: Color(e2.value.key), | ||||
|                             visualDensity: VisualDensity.compact, | ||||
|                             selected: e2.value.value, | ||||
|                             onSelected: (value) { | ||||
|                               setState(() { | ||||
|                                 (values[widget.items[r][e].key] as Map<String, | ||||
|                                         MapEntry<int, bool>>)[e2.key] = | ||||
|                                     MapEntry( | ||||
|                                         (values[widget.items[r][e].key] as Map< | ||||
|                                                 String, | ||||
|                                                 MapEntry<int, bool>>)[key]! | ||||
|                                                 MapEntry<int, bool>>)[e2.key]! | ||||
|                                             .key, | ||||
|                                         false); | ||||
|                                         value); | ||||
|                                 if ((widget.items[r][e] | ||||
|                                             as GeneratedFormTagInput) | ||||
|                                         .singleSelect && | ||||
|                                     value == true) { | ||||
|                                   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< | ||||
|                                                           String, | ||||
|                                                           MapEntry<int, | ||||
|                                                               bool>>)[key]! | ||||
|                                                   .key, | ||||
|                                               false); | ||||
|                                     } | ||||
|                                   } | ||||
|                                 } | ||||
|                               } | ||||
|                               someValueChanged(); | ||||
|                             }); | ||||
|                                 someValueChanged(); | ||||
|                               }); | ||||
|                             }, | ||||
|                           )); | ||||
|                     }) ?? | ||||
|                     [const SizedBox.shrink()], | ||||
|                 (values[widget.items[r][e].key] | ||||
|                                 as Map<String, MapEntry<int, bool>>?) | ||||
|                             ?.values | ||||
|                             .where((e) => e.value) | ||||
|                             .isNotEmpty == | ||||
|                         true | ||||
|                     ? Padding( | ||||
|                         padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                         child: IconButton( | ||||
|                           onPressed: () { | ||||
|                             fn() { | ||||
|                               setState(() { | ||||
|                                 var temp = values[widget.items[r][e].key] | ||||
|                                     as Map<String, MapEntry<int, bool>>; | ||||
|                                 temp.removeWhere((key, value) => value.value); | ||||
|                                 values[widget.items[r][e].key] = temp; | ||||
|                                 someValueChanged(); | ||||
|                               }); | ||||
|                             } | ||||
|  | ||||
|                             if ((widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                     .deleteConfirmationMessage != | ||||
|                                 null) { | ||||
|                               var message = | ||||
|                                   (widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                       .deleteConfirmationMessage!; | ||||
|                               showDialog<Map<String, dynamic>?>( | ||||
|                                   context: context, | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return GeneratedFormModal( | ||||
|                                         title: message.key, | ||||
|                                         message: message.value, | ||||
|                                         items: const []); | ||||
|                                   }).then((value) { | ||||
|                                 if (value != null) { | ||||
|                                   fn(); | ||||
|                                 } | ||||
|                               }); | ||||
|                             } else { | ||||
|                               fn(); | ||||
|                             } | ||||
|                           }, | ||||
|                         )); | ||||
|                   }) ?? | ||||
|                   [const SizedBox.shrink()], | ||||
|               (values[widget.items[r][e].key] | ||||
|                               as Map<String, MapEntry<int, bool>>?) | ||||
|                           ?.values | ||||
|                           .where((e) => e.value) | ||||
|                           .isNotEmpty == | ||||
|                       true | ||||
|                   ? Padding( | ||||
|                       padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                       child: IconButton( | ||||
|                         onPressed: () { | ||||
|                           fn() { | ||||
|                           icon: const Icon(Icons.remove), | ||||
|                           visualDensity: VisualDensity.compact, | ||||
|                           tooltip: tr('remove'), | ||||
|                         )) | ||||
|                     : const SizedBox.shrink(), | ||||
|                 Padding( | ||||
|                     padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                     child: IconButton( | ||||
|                       onPressed: () { | ||||
|                         showDialog<Map<String, dynamic>?>( | ||||
|                             context: context, | ||||
|                             builder: (BuildContext ctx) { | ||||
|                               return GeneratedFormModal( | ||||
|                                   title: widget.items[r][e].label, | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormTextField('label', | ||||
|                                           label: tr('label')) | ||||
|                                     ] | ||||
|                                   ]); | ||||
|                             }).then((value) { | ||||
|                           String? label = value?['label']; | ||||
|                           if (label != null) { | ||||
|                             setState(() { | ||||
|                               var temp = values[widget.items[r][e].key] | ||||
|                                   as Map<String, MapEntry<int, bool>>; | ||||
|                               temp.removeWhere((key, value) => value.value); | ||||
|                               values[widget.items[r][e].key] = temp; | ||||
|                               someValueChanged(); | ||||
|                             }); | ||||
|                           } | ||||
|  | ||||
|                           if ((widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                   .deleteConfirmationMessage != | ||||
|                               null) { | ||||
|                             var message = | ||||
|                                 (widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                     .deleteConfirmationMessage!; | ||||
|                             showDialog<Map<String, dynamic>?>( | ||||
|                                 context: context, | ||||
|                                 builder: (BuildContext ctx) { | ||||
|                                   return GeneratedFormModal( | ||||
|                                       title: message.key, | ||||
|                                       message: message.value, | ||||
|                                       items: const []); | ||||
|                                 }).then((value) { | ||||
|                               if (value != null) { | ||||
|                                 fn(); | ||||
|                                   as Map<String, MapEntry<int, bool>>?; | ||||
|                               temp ??= {}; | ||||
|                               if (temp[label] == null) { | ||||
|                                 var singleSelect = (widget.items[r][e] | ||||
|                                         as GeneratedFormTagInput) | ||||
|                                     .singleSelect; | ||||
|                                 var someSelected = temp.entries | ||||
|                                     .where((element) => element.value.value) | ||||
|                                     .isNotEmpty; | ||||
|                                 temp[label] = MapEntry( | ||||
|                                     generateRandomLightColor().value, | ||||
|                                     !(someSelected && singleSelect)); | ||||
|                                 values[widget.items[r][e].key] = temp; | ||||
|                                 someValueChanged(); | ||||
|                               } | ||||
|                             }); | ||||
|                           } else { | ||||
|                             fn(); | ||||
|                           } | ||||
|                         }, | ||||
|                         icon: const Icon(Icons.remove), | ||||
|                         visualDensity: VisualDensity.compact, | ||||
|                         tooltip: tr('remove'), | ||||
|                       )) | ||||
|                   : const SizedBox.shrink(), | ||||
|               Padding( | ||||
|                   padding: const EdgeInsets.symmetric(horizontal: 4), | ||||
|                   child: IconButton( | ||||
|                     onPressed: () { | ||||
|                       showDialog<Map<String, dynamic>?>( | ||||
|                           context: context, | ||||
|                           builder: (BuildContext ctx) { | ||||
|                             return GeneratedFormModal( | ||||
|                                 title: widget.items[r][e].label, | ||||
|                                 items: [ | ||||
|                                   [ | ||||
|                                     GeneratedFormTextField('label', | ||||
|                                         label: tr('label')) | ||||
|                                   ] | ||||
|                                 ]); | ||||
|                           }).then((value) { | ||||
|                         String? label = value?['label']; | ||||
|                         if (label != null) { | ||||
|                           setState(() { | ||||
|                             var temp = values[widget.items[r][e].key] | ||||
|                                 as Map<String, MapEntry<int, bool>>?; | ||||
|                             temp ??= {}; | ||||
|                             var singleSelect = | ||||
|                                 (widget.items[r][e] as GeneratedFormTagInput) | ||||
|                                     .singleSelect; | ||||
|                             var someSelected = temp.entries | ||||
|                                 .where((element) => element.value.value) | ||||
|                                 .isNotEmpty; | ||||
|                             temp[label] = MapEntry( | ||||
|                                 generateRandomLightColor().value, | ||||
|                                 !(someSelected && singleSelect)); | ||||
|                             values[widget.items[r][e].key] = temp; | ||||
|                             someValueChanged(); | ||||
|                           }); | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|                     icon: const Icon(Icons.add), | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     tooltip: tr('add'), | ||||
|                   )), | ||||
|             ], | ||||
|           ); | ||||
|                         }); | ||||
|                       }, | ||||
|                       icon: const Icon(Icons.add), | ||||
|                       visualDensity: VisualDensity.compact, | ||||
|                       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,24 +58,29 @@ 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( | ||||
|             onPressed: !valid | ||||
|                 ? null | ||||
|                 : () { | ||||
|                     if (valid) { | ||||
|                       HapticFeedback.selectionClick(); | ||||
|                       Navigator.of(context).pop(values); | ||||
|                     } | ||||
|                   }, | ||||
|             child: Text(tr('continue'))) | ||||
|             child: Text(widget.singleNullReturnButton == null | ||||
|                 ? tr('cancel') | ||||
|                 : widget.singleNullReturnButton!)), | ||||
|         widget.singleNullReturnButton == null | ||||
|             ? TextButton( | ||||
|                 onPressed: !valid | ||||
|                     ? null | ||||
|                     : () { | ||||
|                         if (valid) { | ||||
|                           HapticFeedback.selectionClick(); | ||||
|                           Navigator.of(context).pop(values); | ||||
|                         } | ||||
|                       }, | ||||
|                 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.5'; | ||||
| const String currentVersion = '0.9.11'; | ||||
| 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'; | ||||
| @@ -29,6 +30,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|   AppSource? pickedSource; | ||||
|   Map<String, dynamic> additionalSettings = {}; | ||||
|   bool additionalSettingsValid = true; | ||||
|   List<String> pickedCategories = []; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -37,25 +39,19 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|  | ||||
|     changeUserInput(String input, bool valid, bool isBuilding) { | ||||
|       userInput = input; | ||||
|       fn() { | ||||
|         var source = valid ? sourceProvider.getSource(userInput) : null; | ||||
|         if (pickedSource.runtimeType != source.runtimeType) { | ||||
|           pickedSource = source; | ||||
|           additionalSettings = source != null | ||||
|               ? getDefaultValuesFromFormItems( | ||||
|                   source.combinedAppSpecificSettingFormItems) | ||||
|               : {}; | ||||
|           additionalSettingsValid = source != null | ||||
|               ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) | ||||
|               : true; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (isBuilding) { | ||||
|         fn(); | ||||
|       } else { | ||||
|       if (!isBuilding) { | ||||
|         setState(() { | ||||
|           fn(); | ||||
|           var source = valid ? sourceProvider.getSource(userInput) : null; | ||||
|           if (pickedSource.runtimeType != source.runtimeType) { | ||||
|             pickedSource = source; | ||||
|             additionalSettings = source != null | ||||
|                 ? getDefaultValuesFromFormItems( | ||||
|                     source.combinedAppSpecificSettingFormItems) | ||||
|                 : {}; | ||||
|             additionalSettingsValid = source != null | ||||
|                 ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) | ||||
|                 : true; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
| @@ -131,6 +127,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|           if (app.additionalSettings['trackOnly'] == true) { | ||||
|             app.installedVersion = app.latestVersion; | ||||
|           } | ||||
|           app.categories = pickedCategories; | ||||
|           await appsProvider.saveApps([app]); | ||||
|  | ||||
|           return app; | ||||
| @@ -238,7 +235,9 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     if (values.isNotEmpty && valid) { | ||||
|                                     if (values.isNotEmpty && | ||||
|                                         valid && | ||||
|                                         !isBuilding) { | ||||
|                                       setState(() { | ||||
|                                         searchQuery = | ||||
|                                             values['searchSomeSources']!.trim(); | ||||
| @@ -289,7 +288,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                           if (selectedUrls != null && | ||||
|                                               selectedUrls.isNotEmpty) { | ||||
|                                             changeUserInput( | ||||
|                                                 selectedUrls[0], true, true); | ||||
|                                                 selectedUrls[0], true, false); | ||||
|                                             addApp(resetUserInputAfter: true); | ||||
|                                           } | ||||
|                                         }).catchError((e) { | ||||
| @@ -299,9 +298,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                 child: Text(tr('search'))) | ||||
|                           ], | ||||
|                         ), | ||||
|                       if (pickedSource != null && | ||||
|                           (pickedSource! | ||||
|                               .combinedAppSpecificSettingFormItems.isNotEmpty)) | ||||
|                       if (pickedSource != null) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                           children: [ | ||||
| @@ -328,6 +325,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,45 +72,44 @@ class AppsPageState extends State<AppsPage> { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (filter != null) { | ||||
|       sortedApps = sortedApps.where((app) { | ||||
|         if (app.app.installedVersion == app.app.latestVersion && | ||||
|             !(filter!.includeUptodate)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (app.app.installedVersion == null && | ||||
|             !(filter!.includeNonInstalled)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (filter!.nameFilter.isNotEmpty || filter!.authorFilter.isNotEmpty) { | ||||
|           List<String> nameTokens = filter!.nameFilter | ||||
|               .split(' ') | ||||
|               .where((element) => element.trim().isNotEmpty) | ||||
|               .toList(); | ||||
|           List<String> authorTokens = filter!.authorFilter | ||||
|               .split(' ') | ||||
|               .where((element) => element.trim().isNotEmpty) | ||||
|               .toList(); | ||||
|     sortedApps = sortedApps.where((app) { | ||||
|       if (app.app.installedVersion == app.app.latestVersion && | ||||
|           !(filter.includeUptodate)) { | ||||
|         return false; | ||||
|       } | ||||
|       if (app.app.installedVersion == null && !(filter.includeNonInstalled)) { | ||||
|         return false; | ||||
|       } | ||||
|       if (filter.nameFilter.isNotEmpty || filter.authorFilter.isNotEmpty) { | ||||
|         List<String> nameTokens = filter.nameFilter | ||||
|             .split(' ') | ||||
|             .where((element) => element.trim().isNotEmpty) | ||||
|             .toList(); | ||||
|         List<String> authorTokens = filter.authorFilter | ||||
|             .split(' ') | ||||
|             .where((element) => element.trim().isNotEmpty) | ||||
|             .toList(); | ||||
|  | ||||
|           for (var t in nameTokens) { | ||||
|             var name = app.installedInfo?.name ?? app.app.name; | ||||
|             if (!name.toLowerCase().contains(t.toLowerCase())) { | ||||
|               return false; | ||||
|             } | ||||
|           } | ||||
|           for (var t in authorTokens) { | ||||
|             if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { | ||||
|               return false; | ||||
|             } | ||||
|         for (var t in nameTokens) { | ||||
|           var name = app.installedInfo?.name ?? app.app.name; | ||||
|           if (!name.toLowerCase().contains(t.toLowerCase())) { | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
|         if (filter!.categoryFilter.isNotEmpty && | ||||
|             filter!.categoryFilter != app.app.category) { | ||||
|           return false; | ||||
|         for (var t in authorTokens) { | ||||
|           if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
|         return true; | ||||
|       }).toList(); | ||||
|     } | ||||
|       } | ||||
|       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,21 +347,32 @@ class AppsPageState extends State<AppsPage> { | ||||
|       persistentFooterButtons: [ | ||||
|         Row( | ||||
|           children: [ | ||||
|             IconButton( | ||||
|                 onPressed: () { | ||||
|                   selectedApps.isEmpty | ||||
|                       ? selectThese(sortedApps.map((e) => e.app).toList()) | ||||
|                       : clearSelected(); | ||||
|                 }, | ||||
|                 icon: Icon( | ||||
|                   selectedApps.isEmpty | ||||
|                       ? Icons.select_all_outlined | ||||
|                       : Icons.deselect_outlined, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 tooltip: selectedApps.isEmpty | ||||
|                     ? tr('selectAll') | ||||
|                     : tr('deselectN', args: [selectedApps.length.toString()])), | ||||
|             selectedApps.isEmpty | ||||
|                 ? IconButton( | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     onPressed: () { | ||||
|                       selectThese(sortedApps.map((e) => e.app).toList()); | ||||
|                     }, | ||||
|                     icon: Icon( | ||||
|                       Icons.select_all_outlined, | ||||
|                       color: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                     tooltip: tr('selectAll')) | ||||
|                 : TextButton.icon( | ||||
|                     style: | ||||
|                         const ButtonStyle(visualDensity: VisualDensity.compact), | ||||
|                     onPressed: () { | ||||
|                       selectedApps.isEmpty | ||||
|                           ? selectThese(sortedApps.map((e) => e.app).toList()) | ||||
|                           : clearSelected(); | ||||
|                     }, | ||||
|                     icon: Icon( | ||||
|                       selectedApps.isEmpty | ||||
|                           ? Icons.select_all_outlined | ||||
|                           : Icons.deselect_outlined, | ||||
|                       color: Theme.of(context).colorScheme.primary, | ||||
|                     ), | ||||
|                     label: Text(selectedApps.length.toString())), | ||||
|             const VerticalDivider(), | ||||
|             Expanded( | ||||
|                 child: Row( | ||||
| @@ -486,6 +505,73 @@ class AppsPageState extends State<AppsPage> { | ||||
|                     icon: const Icon( | ||||
|                       Icons.file_download_outlined, | ||||
|                     )), | ||||
|                 selectedApps.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
|                         visualDensity: VisualDensity.compact, | ||||
|                         onPressed: () 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: 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), | ||||
|                       ), | ||||
|                 selectedApps.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
| @@ -663,7 +749,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|               onPressed: () { | ||||
|                 setState(() { | ||||
|                   if (currentFilterIsUpdatesOnly) { | ||||
|                     filter = null; | ||||
|                     filter = AppsFilter(); | ||||
|                   } else { | ||||
|                     filter = updatesOnlyFilter; | ||||
|                   } | ||||
| @@ -682,10 +768,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,44 +784,48 @@ 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( | ||||
|                                 title: tr('filterApps'), | ||||
|                                 items: [ | ||||
|                                   [ | ||||
|                                     GeneratedFormTextField('appName', | ||||
|                                         label: tr('appName'), | ||||
|                                         required: false, | ||||
|                                         defaultValue: vals['appName']), | ||||
|                                     GeneratedFormTextField('author', | ||||
|                                         label: tr('author'), | ||||
|                                         required: false, | ||||
|                                         defaultValue: vals['author']) | ||||
|                                   ], | ||||
|                                   [ | ||||
|                                     GeneratedFormSwitch('upToDateApps', | ||||
|                                         label: tr('upToDateApps'), | ||||
|                                         defaultValue: vals['upToDateApps']) | ||||
|                                   ], | ||||
|                                   [ | ||||
|                                     GeneratedFormSwitch('nonInstalledApps', | ||||
|                                         label: tr('nonInstalledApps'), | ||||
|                                         defaultValue: vals['nonInstalledApps']) | ||||
|                                   ], | ||||
|                                   [ | ||||
|                                     settingsProvider.getCategoryFormItem( | ||||
|                                         initCategory: vals['category'] ?? '') | ||||
|                                   ] | ||||
|                                 ]); | ||||
|                               initValid: true, | ||||
|                               title: tr('filterApps'), | ||||
|                               items: [ | ||||
|                                 [ | ||||
|                                   GeneratedFormTextField('appName', | ||||
|                                       label: tr('appName'), | ||||
|                                       required: false, | ||||
|                                       defaultValue: vals['appName']), | ||||
|                                   GeneratedFormTextField('author', | ||||
|                                       label: tr('author'), | ||||
|                                       required: false, | ||||
|                                       defaultValue: vals['author']) | ||||
|                                 ], | ||||
|                                 [ | ||||
|                                   GeneratedFormSwitch('upToDateApps', | ||||
|                                       label: tr('upToDateApps'), | ||||
|                                       defaultValue: vals['upToDateApps']) | ||||
|                                 ], | ||||
|                                 [ | ||||
|                                   GeneratedFormSwitch('nonInstalledApps', | ||||
|                                       label: tr('nonInstalledApps'), | ||||
|                                       defaultValue: vals['nonInstalledApps']) | ||||
|                                 ] | ||||
|                               ], | ||||
|                               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 +843,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); | ||||
| } | ||||
|   | ||||
| @@ -66,6 +66,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                             showError( | ||||
|                                                 tr('exportedTo', args: [path]), | ||||
|                                                 context); | ||||
|                                           }).catchError((e) { | ||||
|                                             showError(e, context); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                   child: Text(tr('obtainiumExport')))), | ||||
| @@ -338,7 +340,9 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 () async { | ||||
|                                                   var values = await showDialog( | ||||
|                                                   var values = await showDialog< | ||||
|                                                           Map<String, | ||||
|                                                               dynamic>?>( | ||||
|                                                       context: context, | ||||
|                                                       builder: | ||||
|                                                           (BuildContext ctx) { | ||||
| @@ -365,7 +369,10 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                     var urlsWithDescriptions = | ||||
|                                                         await source | ||||
|                                                             .getUrlsWithDescriptions( | ||||
|                                                                 values); | ||||
|                                                                 values.values | ||||
|                                                                     .map((e) => | ||||
|                                                                         e.toString()) | ||||
|                                                                     .toList()); | ||||
|                                                     var selectedUrls = | ||||
|                                                         await showDialog< | ||||
|                                                                 List<String>?>( | ||||
|   | ||||
| @@ -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'; | ||||
| @@ -706,6 +707,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; | ||||
| } | ||||
|   | ||||
| @@ -48,7 +48,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 +61,7 @@ class App { | ||||
|       this.additionalSettings, | ||||
|       this.lastUpdateCheck, | ||||
|       this.pinned, | ||||
|       {this.category}); | ||||
|       {this.categories = const []}); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
| @@ -103,6 +103,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 +121,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,7 +148,7 @@ class App { | ||||
|         'additionalSettings': jsonEncode(additionalSettings), | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||
|         'pinned': pinned, | ||||
|         'category': category | ||||
|         'categories': categories | ||||
|       }; | ||||
| } | ||||
|  | ||||
| @@ -331,13 +341,13 @@ class SourceProvider { | ||||
|       {App? currentApp, | ||||
|       bool trackOnlyOverride = false, | ||||
|       noVersionDetectionOverride = false}) async { | ||||
|     if (trackOnlyOverride) { | ||||
|     if (trackOnlyOverride || source.enforceTrackOnly) { | ||||
|       additionalSettings['trackOnly'] = true; | ||||
|     } | ||||
|     if (noVersionDetectionOverride) { | ||||
|       additionalSettings['noVersionDetection'] = true; | ||||
|     } | ||||
|     var trackOnly = currentApp?.additionalSettings['trackOnly'] == true; | ||||
|     var trackOnly = additionalSettings['trackOnly'] == true; | ||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||
|     APKDetails apk = | ||||
|         await source.getLatestAPKDetails(standardUrl, additionalSettings); | ||||
| @@ -360,11 +370,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 | ||||
|   | ||||
| @@ -739,14 +739,14 @@ 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.1" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -760,7 +760,7 @@ packages: | ||||
|       name: webview_flutter_wkwebview | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "3.0.1" | ||||
|   win32: | ||||
|     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.5+93 # When changing this, update the tag in main() accordingly | ||||
| version: 0.9.11+101 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
|   | ||||
		Reference in New Issue
	
	Block a user