mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-11-03 23:03:29 +01:00 
			
		
		
		
	Compare commits
	
		
			26 Commits
		
	
	
		
			v0.9.8-bet
			...
			v0.9.12-be
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					0a93117bf0 | ||
| 
						 | 
					451cc41c45 | ||
| 
						 | 
					3b449d0982 | ||
| 
						 | 
					1863f55372 | ||
| 
						 | 
					0c4b8ac79d | ||
| 
						 | 
					e287087753 | ||
| 
						 | 
					82bcc46d42 | ||
| 
						 | 
					1f26188ec6 | ||
| 
						 | 
					794c3e1a81 | ||
| 
						 | 
					16369b4adf | ||
| 
						 | 
					8f16f745be | ||
| 
						 | 
					8ddeb3d776 | ||
| 
						 | 
					21cf9c98d9 | ||
| 
						 | 
					358f910d19 | ||
| 
						 | 
					7a3d74bd05 | ||
| 
						 | 
					6f27f64699 | ||
| 
						 | 
					3341fecb68 | ||
| 
						 | 
					d3bce63ca4 | ||
| 
						 | 
					8aa8b6b698 | ||
| 
						 | 
					3d6c9bbf98 | ||
| 
						 | 
					7af0a8628c | ||
| 
						 | 
					4573ce6bcf | ||
| 
						 | 
					e29d38fa32 | ||
| 
						 | 
					dc82431235 | ||
| 
						 | 
					424b0028bf | ||
| 
						 | 
					46fba9e0a4 | 
@@ -1,4 +1,4 @@
 | 
			
		||||
#  Obtainium
 | 
			
		||||
#  Obtainium
 | 
			
		||||
 | 
			
		||||
Get Android App Updates Directly From the Source.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
@@ -209,6 +209,8 @@
 | 
			
		||||
    "addCategory": "Aggiungi categoria",
 | 
			
		||||
    "label": "Etichetta",
 | 
			
		||||
    "language": "Lingua",
 | 
			
		||||
    "storagePermissionDenied": "Storage permission denied",
 | 
			
		||||
    "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
 | 
			
		||||
    "tooManyRequestsTryAgainInMinutes": {
 | 
			
		||||
        "one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
 | 
			
		||||
        "other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
 | 
			
		||||
 
 | 
			
		||||
@@ -209,6 +209,8 @@
 | 
			
		||||
    "addCategory": "カテゴリを追加",
 | 
			
		||||
    "label": "ラベル",
 | 
			
		||||
    "language": "言語",
 | 
			
		||||
    "storagePermissionDenied": "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 限制) - 在 {} 分钟后重试"
 | 
			
		||||
 
 | 
			
		||||
@@ -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.8';
 | 
			
		||||
const String currentVersion = '0.9.12';
 | 
			
		||||
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,75 @@ 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: !showPrompt
 | 
			
		||||
                                              ? preselected ?? {}
 | 
			
		||||
                                              : {},
 | 
			
		||||
                                          showLabelWhenNotEmpty: false,
 | 
			
		||||
                                          onSelected: (categories) {
 | 
			
		||||
                                            appsProvider
 | 
			
		||||
                                                .saveApps(selectedApps.map((e) {
 | 
			
		||||
                                              e.categories = categories;
 | 
			
		||||
                                              return e;
 | 
			
		||||
                                            }).toList());
 | 
			
		||||
                                          },
 | 
			
		||||
                                        )
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    );
 | 
			
		||||
                                  });
 | 
			
		||||
                            }
 | 
			
		||||
                          } catch (err) {
 | 
			
		||||
                            showError(err, context);
 | 
			
		||||
                          }
 | 
			
		||||
                        },
 | 
			
		||||
                        tooltip: tr('categorize'),
 | 
			
		||||
                        icon: const Icon(Icons.category_outlined),
 | 
			
		||||
                      ),
 | 
			
		||||
                selectedApps.isEmpty
 | 
			
		||||
                    ? const SizedBox()
 | 
			
		||||
                    : IconButton(
 | 
			
		||||
@@ -663,7 +751,7 @@ class AppsPageState extends State<AppsPage> {
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                setState(() {
 | 
			
		||||
                  if (currentFilterIsUpdatesOnly) {
 | 
			
		||||
                    filter = null;
 | 
			
		||||
                    filter = AppsFilter();
 | 
			
		||||
                  } else {
 | 
			
		||||
                    filter = updatesOnlyFilter;
 | 
			
		||||
                  }
 | 
			
		||||
@@ -682,10 +770,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 +786,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 +845,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')))),
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -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.8+96 # When changing this, update the tag in main() accordingly
 | 
			
		||||
version: 0.9.12+102 # When changing this, update the tag in main() accordingly
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: '>=2.18.2 <3.0.0'
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user