diff --git a/.fdroid.yml b/.fdroid.yml index 51114a4f..89999c58 100644 --- a/.fdroid.yml +++ b/.fdroid.yml @@ -10,16 +10,7 @@ Donate: https://openintents.org/contribute Bitcoin: 16MJeiftw9LRuvqoSH18v16kYTeNx7wJSZ AutoName: OI Shopping List -Description: |- - OI Shopping List is a powerful application which makes it easy to create and - manage checklists. OI Shopping List makes it easy to add items to a list, check - the item off, and remove it from the list. - - The application can track separate prices for each store that you use, and - features a high level of customization. Font size, sort order, list columns, and - list cleanup behavior are all settings controlled by the user. All of these - features are brought together in a crisp layout that is customizable through a - variety of themes. +# Summary, description, changelogs and icon come from fastlane/metadata/android/. RepoType: git Repo: https://github.com/openintents/shoppinglist @@ -67,7 +58,15 @@ Builds: - rm -rf ../ShoppingListWear - sed -i '/ShoppingListWear/ d' ../settings.gradle + # AGP 8 / AndroidX / targetSdk 35. Builds as-is: no prebuild patching needed. + - versionName: 2.3.0 + versionCode: 100230 + commit: v2.3.0 + subdir: ShoppingList + gradle: + - tstore + AutoUpdateMode: Version v%v UpdateCheckMode: Tags -CurrentVersion: 2.2.1 -CurrentVersionCode: 100221 +CurrentVersion: 2.3.0 +CurrentVersionCode: 100230 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..9754fb93 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + - name: Unit tests + run: ./gradlew --no-daemon :ShoppingList:testPlayDebugUnitTest + - name: Build release APK (F-Droid flavor) and AAB (Play) + run: ./gradlew --no-daemon :ShoppingList:assembleTstoreRelease :ShoppingList:bundlePlayRelease + - name: Lint + run: ./gradlew --no-daemon :ShoppingList:lintPlayRelease + - uses: actions/upload-artifact@v4 + if: always() + with: + name: outputs + path: | + ShoppingList/build/outputs/apk/ + ShoppingList/build/outputs/bundle/ + ShoppingList/build/reports/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4fd07421 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,137 @@ +name: Release + +# Push a tag like v2.3.0 to build a signed APK, publish it as a GitHub release +# (the source for Obtainium), to Zapstore and as a beta on Google Play. +# F-Droid builds the same tag itself. +# +# Repository secrets: +# KEYSTORE_BASE64 base64 of the release keystore (base64 -w0 release.jks) +# KEYSTORE_PASSWORD keystore password +# KEY_ALIAS key alias +# KEY_PASSWORD key password +# ZAPSTORE_SIGN_WITH optional: nsec or NIP-46 bunker URL of the Zapstore publisher +# PLAY_SERVICE_ACCOUNT_JSON optional: JSON key of a Google Cloud service account +# with "Release to testing tracks" in the Play Console. Without it +# the Play step is skipped. +# PLAY_KEYSTORE_BASE64, PLAY_KEYSTORE_PASSWORD, PLAY_KEY_ALIAS, PLAY_KEY_PASSWORD +# optional: the Play upload key, if it differs from the key above. +# +# The Play release goes to the open testing track ("beta"). Promote it to +# production in the Play Console (Test and release > Open testing > Promote release). + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + + - name: Signing config + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + if [ -z "$KEYSTORE_BASE64" ]; then + echo "::error::Add the KEYSTORE_BASE64/KEYSTORE_PASSWORD/KEY_ALIAS/KEY_PASSWORD secrets to sign releases." + exit 1 + fi + echo "$KEYSTORE_BASE64" | base64 -d > ShoppingList/release.jks + { + echo "key.store=release.jks" + echo "key.store.password=$KEYSTORE_PASSWORD" + echo "key.alias=$KEY_ALIAS" + echo "key.alias.password=$KEY_PASSWORD" + } > ShoppingList/build-private.properties + + - name: Unit tests + run: ./gradlew --no-daemon :ShoppingList:testPlayDebugUnitTest + + - name: Build signed APK + run: ./gradlew --no-daemon :ShoppingList:assembleTstoreRelease + + - name: Release notes + id: notes + run: | + CODE=$(grep -oP 'versionCode \K[0-9]+' ShoppingList/build.gradle) + cp "fastlane/metadata/android/en-US/changelogs/$CODE.txt" release-notes.txt + + - name: GitHub release + uses: softprops/action-gh-release@v2 + with: + body_path: release-notes.txt + files: ShoppingList/build/outputs/apk/tstore/release/*.apk + + - name: Publish to Zapstore + env: + SIGN_WITH: ${{ secrets.ZAPSTORE_SIGN_WITH }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: env.SIGN_WITH != '' + run: | + git clone --depth 1 https://github.com/zapstore/zsp /tmp/zsp + (cd /tmp/zsp && go build -o /tmp/zsp-bin .) + /tmp/zsp-bin publish -q zapstore.yaml + + - name: Play upload key + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + PLAY_KEYSTORE_BASE64: ${{ secrets.PLAY_KEYSTORE_BASE64 }} + PLAY_KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD }} + PLAY_KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS }} + PLAY_KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD }} + if: env.PLAY_SERVICE_ACCOUNT_JSON != '' && env.PLAY_KEYSTORE_BASE64 != '' + run: | + echo "$PLAY_KEYSTORE_BASE64" | base64 -d > ShoppingList/play-upload.jks + { + echo "key.store=play-upload.jks" + echo "key.store.password=$PLAY_KEYSTORE_PASSWORD" + echo "key.alias=$PLAY_KEY_ALIAS" + echo "key.alias.password=$PLAY_KEY_PASSWORD" + } > ShoppingList/build-private.properties + + - name: Build Play bundle + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + if: env.PLAY_SERVICE_ACCOUNT_JSON != '' + run: | + ./gradlew --no-daemon :ShoppingList:bundlePlayRelease + # Release notes: fastlane changelogs -> whatsnew- + CODE=$(grep -oP 'versionCode \K[0-9]+' ShoppingList/build.gradle) + mkdir -p whatsnew + for f in fastlane/metadata/android/*/changelogs/$CODE.txt; do + dir=${f#fastlane/metadata/android/}; dir=${dir%%/*} + case "$dir" in + *-*) lang=$dir ;; + de|fr|es|it|nl|pl|pt|cs|sk|fi|sv|ru|tr) lang="$dir-${dir^^}" ;; + ja) lang=ja-JP ;; + *) echo "::warning::No Play language code for $dir, skipping its release notes"; continue ;; + esac + cp "$f" "whatsnew/whatsnew-$lang" + done + ls whatsnew + + - name: Publish beta on Google Play + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + if: env.PLAY_SERVICE_ACCOUNT_JSON != '' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: org.openintents.shopping + releaseFiles: ShoppingList/build/outputs/bundle/playRelease/*.aab + mappingFile: ShoppingList/build/outputs/mapping/playRelease/mapping.txt + whatsNewDirectory: whatsnew + track: beta + status: completed diff --git a/.gitignore b/.gitignore index 8d46f956..c4295551 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ build .DS_Store *.p12 .claude/ +*.jks +*.keystore +.kotlin/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e16547ee..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: android -android: - components: - - tools - - platform-tools - - tools - - build-tools-26.0.2 - licenses: - - android-sdk-license-bcbbd656 - - '.*intel.+' -env: - matrix: - - ANDROID_SDKS=android-25,sysimg-25 ANDROID_TARGET=android-25 ANDROID_ABI=armeabi-v7a -before_install: - - android list sdk --extended --no-ui --all - - echo y | android update sdk --no-ui --all --filter sys-img-armeabi-v7a-android-24 - - echo no | android create avd --force -n test -t android-24 --abi $ANDROID_ABI - - emulator -avd test -no-skin -no-audio -no-window & -before_script: - - chmod a+x ./ci/wait_for_emulator - - ./ci/wait_for_emulator - - adb shell input keyevent 82 & diff --git a/DistributionLibrary/src/main/res/values-fa/strings.xml b/DistributionLibrary/src/main/res/values-fa/strings.xml index dedf4783..bfff4f0d 100644 --- a/DistributionLibrary/src/main/res/values-fa/strings.xml +++ b/DistributionLibrary/src/main/res/values-fa/strings.xml @@ -24,7 +24,7 @@ - لطفا برای خواندن و پذیرفتن توافقنامه مجوز کاربر نهایی زیر (EULA) برای s% با + لطفا برای خواندن و پذیرفتن توافقنامه مجوز کاربر نهایی زیر (EULA) برای %s با پذیرش @@ -38,7 +38,7 @@ %1$s %2$s - به این ورژن جدید از خوش آمدید s% با + به این ورژن جدید از خوش آمدید %s با تغییرات اخیر : @@ -101,12 +101,12 @@ وب سایت دانلود - دانلود s% + دانلود %s %s can be used with the following applications: - پرت‌ كردن‌ s% + پرت‌ كردن‌ %s %1$s requires %2$s %3$s or higher. diff --git a/DistributionLibrary/src/main/res/values-sd/strings.xml b/DistributionLibrary/src/main/res/values-sd/strings.xml index b914eecd..b53ebf3e 100644 --- a/DistributionLibrary/src/main/res/values-sd/strings.xml +++ b/DistributionLibrary/src/main/res/values-sd/strings.xml @@ -22,7 +22,7 @@ - |s जे लाये हेठ लिख्यल एंड यूज़र लायसेंस एग्रीमेंट (EULA) पढ़यो अऊँ स्वीकार क्यो% + %s जे लाये हेठ लिख्यल एंड यूज़र लायसेंस एग्रीमेंट (EULA) पढ़यो अऊँ स्वीकार क्यो |स्वीकार क्यो @@ -36,7 +36,7 @@ %1$s %2$s - |s जे हिन्न नए संस्करण में तहन्जो स्वागत तव% + %s जे हिन्न नए संस्करण में तहन्जो स्वागत तव |हालिय में थ्यल बदलाव diff --git a/README.md b/README.md index 6c9d77ae..53f2edc1 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,23 @@ All of these features are brought together in a crisp layout that is customizabl * **[F-Droid](https://f-droid.org/en/packages/org.openintents.shopping/)** [F-Droid meta data](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/org.openintents.shopping.yml) * **[Google Play](https://play.google.com/store/apps/details?id=org.openintents.shopping)** + +* **[Obtainium](https://github.com/ImranR98/Obtainium)**: add the app with the URL + `https://github.com/openintents/shoppinglist` (APKs from the GitHub releases) + +* **[Zapstore](https://zapstore.dev)**: search for "OI Shopping List" + +## Releasing + +1. Bump `versionName`/`versionCode` in `ShoppingList/build.gradle`, add + `fastlane/metadata/android/*/changelogs/.txt` (max. 500 characters) + and point `release_notes` in `zapstore.yaml` at it. +2. Push a tag `v`. The *Release* workflow builds and signs the APK, + creates the GitHub release (Obtainium), publishes to Zapstore and uploads an + app bundle to the open testing (beta) track on Google Play. + F-Droid picks up the tag by itself (see `.fdroid.yml`). +3. When the beta is fine, promote it in the Play Console + (Test and release → Open testing → Promote release → Production). + +The secrets the workflow needs are listed at the top of +`.github/workflows/release.yml`. diff --git a/ShoppingList/build.gradle b/ShoppingList/build.gradle index cb11dcf7..366a94f0 100644 --- a/ShoppingList/build.gradle +++ b/ShoppingList/build.gradle @@ -8,11 +8,19 @@ android { defaultConfig { applicationId "org.openintents.shopping" - versionName "2.2.1" - versionCode 100221 + versionName "2.3.0" + versionCode 100230 minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion + // Only the languages the app is translated into: drops the unused + // translations of the libraries (about 80 more locales) from the APK. + resourceConfigurations += ["en", "ar", "be", "bg", "ca", "cs", "da", "de", "el", + "en-rGB", "es", "fa", "fi", "fr", "hi", "hr", "hu", "it", + "iw", "ja", "ko", "mk", "nb", "nl", "oc", "pa", "pl", "pt", + "pt-rBR", "ro", "ru", "sd", "sk", "sr", "sv", "tl", "tr", + "ug", "uk", "zh-rCN", "zh-rTW"] + testApplicationId "org.openintents.shopping.test" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" setProperty("archivesBaseName", "$applicationId-$versionName") @@ -31,6 +39,10 @@ android { unitTests { includeAndroidResources = true returnDefaultValues = true + all { + // ScreenshotTest renders the UIs to build/screenshots only when asked to. + if (project.hasProperty('screenshots')) systemProperty 'screenshots', 'true' + } } } diff --git a/ShoppingList/build.xml b/ShoppingList/build.xml deleted file mode 100644 index 7cde2929..00000000 --- a/ShoppingList/build.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/ShoppingList/proguard.cfg b/ShoppingList/proguard.cfg index 5048adee..74038c15 100644 --- a/ShoppingList/proguard.cfg +++ b/ShoppingList/proguard.cfg @@ -1,55 +1,5 @@ --optimizationpasses 5 --dontusemixedcaseclassnames --dontskipnonpubliclibraryclasses --dontpreverify --verbose --optimizations !code/simplification/arithmetic,!field/*,!class/merging/* - --keep public class * extends android.app.Activity --keep public class * extends android.app.Application --keep public class * extends android.app.Service --keep public class * extends android.content.BroadcastReceiver --keep public class * extends android.content.ContentProvider --keep public class com.android.vending.licensing.ILicensingService - --keepclasseswithmembernames class * { - native ; -} - --keepclasseswithmembernames class * { - public (android.content.Context, android.util.AttributeSet); -} - --keepclasseswithmembernames class * { - public (android.content.Context, android.util.AttributeSet, int); -} - --keepclassmembers enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} - --keep class * implements android.os.Parcelable { - public static final android.os.Parcelable$Creator *; -} +# App-specific R8 rules. Manifest components, views inflated from layouts and +# Parcelables are already kept by the AAPT-generated and default Android rules. +# Referenced by name from the manifest's android:backupAgent attribute. -keep class org.openintents.shopping.ShoppingBackupAgent - --keep class com.pollfish.** { *; } - --keep class * extends java.util.ListResourceBundle { - protected Object[][] getContents(); -} - --keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { - public static final *** NULL; -} - --keepnames @com.google.android.gms.common.annotation.KeepName class * --keepclassmembernames class * { - @com.google.android.gms.common.annotation.KeepName *; -} - --keepnames class * implements android.os.Parcelable { - public static final ** CREATOR; -} \ No newline at end of file diff --git a/ShoppingList/src/main/AndroidManifest.xml b/ShoppingList/src/main/AndroidManifest.xml index 2ddd38d6..613f0d40 100644 --- a/ShoppingList/src/main/AndroidManifest.xml +++ b/ShoppingList/src/main/AndroidManifest.xml @@ -62,6 +62,8 @@ + + - - - @@ -99,12 +96,14 @@ android:readPermission="org.openintents.shopping.READ_PERMISSION" android:writePermission="org.openintents.shopping.WRITE_PERMISSION" /> - + @@ -113,23 +112,6 @@ - - - - - - - - - - - - - - - - - @@ -137,18 +119,13 @@ - + + android:exported="true" + android:theme="@style/OIFrameworkTheme"> @@ -175,60 +152,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:label="@string/app_name" + android:theme="@style/OIFrameworkTheme"> @@ -243,48 +173,20 @@ - - - - - - - - - - - @@ -301,27 +203,25 @@ + android:label="@string/menu_settings" + android:theme="@style/OIFrameworkTheme"> - - - + - - - + android:exported="true" + android:theme="@style/OIFrameworkTheme"> diff --git a/ShoppingList/src/main/java/org/openintents/convertcsv/common/ConvertCsvBaseActivity.kt b/ShoppingList/src/main/java/org/openintents/convertcsv/common/ConvertCsvBaseActivity.kt index 23888ab3..acbdc1b9 100644 --- a/ShoppingList/src/main/java/org/openintents/convertcsv/common/ConvertCsvBaseActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/convertcsv/common/ConvertCsvBaseActivity.kt @@ -16,6 +16,7 @@ package org.openintents.convertcsv.common +import android.annotation.SuppressLint import android.app.AlertDialog import android.app.Dialog import android.content.ActivityNotFoundException @@ -403,6 +404,9 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { } catch (e: WrongFormatException) { dispatchError(R.string.wrong_csv_format) Log.i(TAG, "array index out of bounds", e) + } catch (e: RuntimeException) { + dispatchError(R.string.error_reading_file) + Log.e(TAG, "Import failed", e) } smHasWorkerThread = false @@ -411,18 +415,23 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { } open fun getDocumentSize(uri: Uri): Int { - val cursor = contentResolver.query(uri, null, null, null, null, null) + val cursor = try { + contentResolver.query(uri, null, null, null, null, null) + } catch (e: RuntimeException) { + // e.g. SecurityException if the URI permission is no longer granted + Log.w(TAG, "Cannot query document size", e) + null + } var size = -1 try { if (cursor != null && cursor.moveToFirst()) { - val displayName = cursor.getString( - cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - ) + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val displayName = if (nameIndex >= 0) cursor.getString(nameIndex) else null Log.i(TAG, "Display Name: $displayName") val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - if (!cursor.isNull(sizeIndex)) { + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { size = cursor.getInt(sizeIndex) } } @@ -433,14 +442,21 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { } open fun getDocumentName(uri: Uri): String { - val cursor = contentResolver.query(uri, null, null, null, null, null) + val cursor = try { + contentResolver.query(uri, null, null, null, null, null) + } catch (e: RuntimeException) { + // e.g. SecurityException if the URI permission is no longer granted + Log.w(TAG, "Cannot query document name", e) + null + } var displayName: String? = uri.lastPathSegment try { if (cursor != null && cursor.moveToFirst()) { - displayName = cursor.getString( - cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - ) + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + displayName = cursor.getString(nameIndex) + } } } finally { cursor?.close() @@ -503,7 +519,7 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { try { val writer: Writer val enc = getCurrentEncoding() - val pfd: ParcelFileDescriptor = contentResolver.openFileDescriptor(file, "w")!! + val pfd: ParcelFileDescriptor = contentResolver.openFileDescriptor(file, "wt")!! writer = if (enc == null) { OutputStreamWriter(FileOutputStream(pfd.fileDescriptor)) } else { @@ -519,6 +535,9 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { } catch (e: IOException) { dispatchError(R.string.error_writing_file) Log.i(TAG, "IO exception", e) + } catch (e: RuntimeException) { + dispatchError(R.string.error_writing_file) + Log.e(TAG, "Export failed", e) } smHasWorkerThread = false @@ -711,6 +730,7 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { } @Suppress("DEPRECATION") + @SuppressLint("WrongConstant") // the grant flags are masked to READ/WRITE override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { // TODO Auto-generated method stub super.onActivityResult(requestCode, resultCode, data) @@ -721,6 +741,16 @@ open class ConvertCsvBaseActivity : AppCompatActivity() { if (resultCode == RESULT_OK && data != null) { val documentUri = data.data if (documentUri != null) { + // Keep access to the document across reboots. + try { + contentResolver.takePersistableUriPermission( + documentUri, + data.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + ) + } catch (e: RuntimeException) { + // No persistable grant offered (e.g. ACTION_GET_CONTENT providers). + Log.w(TAG, "Could not persist URI permission", e) + } setFileUri(documentUri) } else { setFileUriUnknown() diff --git a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ConvertCsvActivity.kt b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ConvertCsvActivity.kt index 87ea1ca9..57ac842f 100644 --- a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ConvertCsvActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ConvertCsvActivity.kt @@ -68,7 +68,7 @@ open class ConvertCsvActivity : ConvertCsvBaseActivity() { if (mSpinner != null) { mSpinner!!.setOnItemSelectedListener(object : AdapterView.OnItemSelectedListener { override fun onItemSelected( - parentView: AdapterView<*>, selectedItemView: View, position: Int, id: Long + parentView: AdapterView<*>, selectedItemView: View?, position: Int, id: Long ) { updateInfo() } @@ -171,19 +171,21 @@ open class ConvertCsvActivity : ConvertCsvBaseActivity() { // Try the URI with which Convert CSV has been called: val uri: Uri? = intent.data - val c: Cursor? = contentResolver.query( - uri!!, - arrayOf(ShoppingContract.Lists._ID), null, null, null - ) - if (c != null) { - if (c.moveToFirst()) { - listId = c.getLong(0) + if (isShoppingListUri(uri)) { + val c: Cursor? = contentResolver.query( + uri!!, + arrayOf(ShoppingContract.Lists._ID), null, null, null + ) + if (c != null) { + if (c.moveToFirst()) { + listId = c.getLong(0) + } + c.close() } - c.close() } // Use default list if URI is not valid. - if (listId < 0) { + if (listId <= 0) { listId = ShoppingUtils.getDefaultList(this) } return listId @@ -191,7 +193,10 @@ open class ConvertCsvActivity : ConvertCsvBaseActivity() { open fun getListName(listId: Long): String? { var listname: String? = null - val uri: Uri? = intent.data + var uri: Uri? = intent.data + if (!isShoppingListUri(uri)) { + uri = Uri.withAppendedPath(ShoppingContract.Lists.CONTENT_URI, listId.toString()) + } val c: Cursor? = contentResolver.query( uri!!, arrayOf(ShoppingContract.Lists.NAME), null, null, null @@ -204,4 +209,13 @@ open class ConvertCsvActivity : ConvertCsvBaseActivity() { } return listname } + + /** + * The activity may also be started with the URI of a CSV document + * (VIEW text/csv), which must not be queried as a shopping list. + */ + private fun isShoppingListUri(uri: Uri?): Boolean { + return uri != null && ShoppingContract.AUTHORITY == uri.authority && + uri.pathSegments.firstOrNull() == "lists" + } } diff --git a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ExportCsv.kt b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ExportCsv.kt index 38f2f430..be65737d 100644 --- a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ExportCsv.kt +++ b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ExportCsv.kt @@ -119,7 +119,13 @@ class ExportCsv(private val mContext: Context) { ConvertCsvBaseActivity.dispatchConversionProgress(progress++) val itemname = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.ITEM_NAME)) val status = ci.getLong(ci.getColumnIndexOrThrow(ContainsFull.STATUS)) - val percentage = if (status == Status.BOUGHT) 1 else 0 + // 1 = bought, 0 = to buy; removed items as -1 so that an + // import (which maps anything else to REMOVED) keeps them off the list. + val percentage = when (status) { + Status.BOUGHT -> 1 + Status.WANT_TO_BUY -> 0 + else -> -1 + } val tags = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.ITEM_TAGS)) csvwriter.write(itemname) csvwriter.write(percentage) @@ -127,8 +133,10 @@ class ExportCsv(private val mContext: Context) { csvwriter.write(tags) csvwriter.writeNewline() } + ci.close() } } + c.close() } csvwriter.close() @@ -167,15 +175,15 @@ class ExportCsv(private val mContext: Context) { val itemname = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.ITEM_NAME)) val status = ci.getLong(ci.getColumnIndexOrThrow(ContainsFull.STATUS)) val tags = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.ITEM_TAGS)) - val priority = ci.getString(ci.getColumnIndex(ContainsFull.PRIORITY)) - val quantity = ci.getString(ci.getColumnIndex(ContainsFull.QUANTITY)) - val price = ci.getLong(ci.getColumnIndex(ContainsFull.ITEM_PRICE)) + val priority = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.PRIORITY)) + val quantity = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.QUANTITY)) + val price = ci.getLong(ci.getColumnIndexOrThrow(ContainsFull.ITEM_PRICE)) var pricestring = "" if (price != 0L) { pricestring += price.toDouble() / 100.0 } - val unit = ci.getString(ci.getColumnIndex(ContainsFull.ITEM_UNITS)) - val itemId = ci.getInt(ci.getColumnIndex(ContainsFull.ITEM_ID)).toLong() + val unit = ci.getString(ci.getColumnIndexOrThrow(ContainsFull.ITEM_UNITS)) + val itemId = ci.getInt(ci.getColumnIndexOrThrow(ContainsFull.ITEM_ID)).toLong() val statusText = getHandyShopperStatusText(status) @@ -243,10 +251,10 @@ class ExportCsv(private val mContext: Context) { } } - private fun getHandyShopperNote(itemId: Long): String { + private fun getHandyShopperNote(itemId: Long): String? { val uri: Uri = ContentUris.withAppendedId(ShoppingContract.Items.CONTENT_URI, itemId) - var note = "" + var note: String? = null val c1 = mContext.contentResolver.query( uri, arrayOf(ShoppingContract.Items.NOTE), null, null, null @@ -331,7 +339,7 @@ class ExportCsv(private val mContext: Context) { val storeName = c2.getString(c2.getColumnIndexOrThrow(ShoppingContract.Stores.NAME)) if (price != 0L) { - val info = "$storeName=$aisle/$pricestring" + val info = "$storeName=${aisle ?: ""}/$pricestring" perStoreInfo = if (perStoreInfo == "") { info } else { diff --git a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ImportCsv.kt b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ImportCsv.kt index 9a62f171..4227f436 100644 --- a/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ImportCsv.kt +++ b/ShoppingList/src/main/java/org/openintents/convertcsv/shoppinglist/ImportCsv.kt @@ -171,10 +171,12 @@ class ImportCsv(private val mContext: Context, importPolicy: Int) { // example value for column 11: Big Y=/0.50;BJ's=11/0.42 if (nextLine!![11].isNotEmpty() && importStores) { - stores = nextLine!![11].split(";").toTypedArray() + // Unlike Java's split(), Kotlin's keeps trailing empty strings. + stores = nextLine!![11].split(";").filter { it.isNotEmpty() }.toTypedArray() for (i_store in stores.indices) { - val key_vals = stores[i_store].split("=").toTypedArray() + val key_vals = stores[i_store].split("=", limit = 2) + if (key_vals.size < 2) continue val store_name = key_vals[0] val aisle_price = key_vals[1].split("/").toTypedArray() if (aisle_price.isEmpty()) continue @@ -196,7 +198,7 @@ class ImportCsv(private val mContext: Context, importPolicy: Int) { } if (nextLine!![10].isNotEmpty()) { - stores = nextLine!![10].split(";").toTypedArray() + stores = nextLine!![10].split(";").filter { it.isNotEmpty() }.toTypedArray() for (i_store in stores.indices) { if (importStores) { // real store import var storeId = item_stores[stores[i_store]] diff --git a/ShoppingList/src/main/java/org/openintents/provider/Alert.kt b/ShoppingList/src/main/java/org/openintents/provider/Alert.kt index e6cce377..86832552 100644 --- a/ShoppingList/src/main/java/org/openintents/provider/Alert.kt +++ b/ShoppingList/src/main/java/org/openintents/provider/Alert.kt @@ -144,9 +144,9 @@ open class Alert { )!! c.moveToFirst() - minTime = c.getLong(c.getColumnIndex(ManagedService.TIME_INTERVALL)) + minTime = c.getLong(c.getColumnIndexOrThrow(ManagedService.TIME_INTERVALL)) while (!c.isAfterLast) { - val l = c.getLong(c.getColumnIndex(ManagedService.TIME_INTERVALL)) + val l = c.getLong(c.getColumnIndexOrThrow(ManagedService.TIME_INTERVALL)) if (l < minTime) { minTime = l } @@ -213,9 +213,9 @@ open class Alert { )!! c.moveToFirst() - minTime = c.getLong(c.getColumnIndex(ManagedService.TIME_INTERVALL)) + minTime = c.getLong(c.getColumnIndexOrThrow(ManagedService.TIME_INTERVALL)) while (!c.isAfterLast) { - val l = c.getLong(c.getColumnIndex(ManagedService.TIME_INTERVALL)) + val l = c.getLong(c.getColumnIndexOrThrow(ManagedService.TIME_INTERVALL)) if (l < minTime) { minTime = l } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ShoppingActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ShoppingActivity.kt index d70e0e34..3af6620c 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ShoppingActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ShoppingActivity.kt @@ -1,20 +1,14 @@ package org.openintents.shopping +import org.openintents.shopping.ui.compose.ComposeShoppingActivity + /** - * The main activity prior to version 1.4 was ".ShoppingActivity". Home screens - * may still contain a direct link to the old activity, therefore this class - * must never be renamed or moved. + * The app's main screen (the launcher entry). * - * This class is derived from .ui.ShoppingActivity which contains the actual - * implementation. + * The main activity prior to version 1.4 was ".ShoppingActivity". Home screens, + * list shortcuts and the widget link to this component, therefore this class + * must never be renamed or moved. * - * This solution is used instead of using an activity-alias in the Manifest, - * because the activity-alias does not respect the - * android:windowSoftInputMode="stateHidden|adjustResize" setting. + * It hosts the Compose UI (see [ComposeShoppingActivity]). */ -open class ShoppingActivity : org.openintents.shopping.ui.ShoppingActivity() { - - /** - * For the implementation, see .ui.ShoppingActivity. - */ -} +open class ShoppingActivity : ComposeShoppingActivity() diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ShoppingBackupAgent.kt b/ShoppingList/src/main/java/org/openintents/shopping/ShoppingBackupAgent.kt index 346bc582..347d9b37 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ShoppingBackupAgent.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ShoppingBackupAgent.kt @@ -40,7 +40,7 @@ class ShoppingBackupAgent : BackupAgentHelper() { @Throws(IOException::class) override fun onBackup( - oldState: ParcelFileDescriptor, + oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor ) { diff --git a/ShoppingList/src/main/java/org/openintents/shopping/automation/AutomationActions.kt b/ShoppingList/src/main/java/org/openintents/shopping/automation/AutomationActions.kt index 6125b975..3960459c 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/automation/AutomationActions.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/automation/AutomationActions.kt @@ -12,7 +12,8 @@ object AutomationActions { @JvmStatic fun cleanUpList(context: Context, uri: Uri?) { if (uri != null) { - val id = uri.lastPathSegment!!.toInt().toLong() + // The URI comes from an external (automation) intent: ignore malformed ones. + val id = uri.lastPathSegment?.toLongOrNull() ?: return // by changing state val values = ContentValues() diff --git a/ShoppingList/src/main/java/org/openintents/shopping/automation/EditAutomationActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/automation/EditAutomationActivity.kt index 9f96874a..02c03f5d 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/automation/EditAutomationActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/automation/EditAutomationActivity.kt @@ -52,7 +52,7 @@ open class EditAutomationActivity : Activity() { override fun onItemSelected( parent: AdapterView<*>, - view: View, + view: View?, position: Int, id: Long ) { diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ItemArrangement.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ItemArrangement.kt index f1c773c2..afb61ac4 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/ItemArrangement.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ItemArrangement.kt @@ -1,24 +1,12 @@ package org.openintents.shopping.data -/** How the visible items are ordered. */ -enum class SortMode { UNCHECKED_FIRST, ALPHABETICAL } - /** - * Applies the user's filter + sort to the raw item list. Pure business logic - * (no Android), so it is covered by fast JVM tests (ItemArrangementTest). + * Applies the user's filter to the item list. The order comes from the + * repository (the legacy sort orders, see [ShoppingRepository.getSortOrder]); + * this keeps it. Pure business logic, covered by ItemArrangementTest. * * @param hideChecked when true, bought items are dropped from the view (they still * count toward totals, which are computed from the full list). */ -fun arrangeItems( - items: List, - sortMode: SortMode, - hideChecked: Boolean, -): List { - val filtered = if (hideChecked) items.filter { !it.isBought } else items - return when (sortMode) { - SortMode.ALPHABETICAL -> filtered.sortedBy { it.name.lowercase() } - SortMode.UNCHECKED_FIRST -> - filtered.sortedWith(compareBy({ it.isBought }, { it.name.lowercase() })) - } -} +fun arrangeItems(items: List, hideChecked: Boolean): List = + if (hideChecked) items.filter { !it.isBought } else items diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ListTheme.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ListTheme.kt index 00c6fae4..01d63b3a 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/ListTheme.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ListTheme.kt @@ -1,29 +1,71 @@ package org.openintents.shopping.data /** - * Built-in list themes, mirroring the three styles in res/values/themes.xml - * (Default / Classic / Bugdroid). Colors are ARGB; [fontAsset] points at a - * bundled font under assets/ (the Compose layer loads it), or null for the - * default font. The selected theme is stored per-list in Lists.SKIN_BACKGROUND. + * The built-in list themes. The values mirror the legacy styles in + * res/values/themes.xml (Theme.ShoppingList, .Classic, .Android) attribute for + * attribute, so a list looks the same in both UIs. * - * Note: the legacy app can also load themes exported by *other* installed apps - * via ThemeUtils/ThemeAttributes; that resolver would feed values into the same - * shape as this enum. Only the built-in themes are reproduced here. + * Colors are ARGB. [fontAsset] is a font under assets/ (null = default font). + * [paperBackground] draws the Classic notepad paper (res/drawable/shoppinglist01d). + * Text sizes (sp) are indexed by the "fontsize" setting: 0 tiny, 1 small, + * 2 medium (default), 3 large. + * + * The selected theme is stored per list in Lists.SKIN_BACKGROUND as + * [storedValue] ("1"/"2"/"3"), the legacy encoding; [fromName] also accepts the + * legacy style resource names. */ enum class ListTheme( - val displayName: String, + val storedValue: String, val backgroundArgb: Long, + val paperBackground: Boolean, + val textSizesSp: List, val textArgb: Long, + val priceArgb: Long, + val priorityArgb: Long, val checkedTextArgb: Long, val fontAsset: String?, + /** The font only has upper-case glyphs: show text in upper case. */ + val upperCase: Boolean, val strikethroughChecked: Boolean, + /** Append the "... OK" suffix to checked items. */ + val checkedSuffix: Boolean, + val showCheckBox: Boolean, + val showDivider: Boolean, ) { - DEFAULT("Default", 0xFF121212, 0xFFFFFFFF, 0xFF8A8A8A, null, true), - CLASSIC("Classic", 0xFFF5ECD9, 0xFF000000, 0xFF7A7A6A, "fonts/AnkeHand.ttf", true), - ANDROID("Bugdroid", 0xFF1A1A1A, 0xFFFFFF66, 0xFF8A8A55, "fonts/Crysta.ttf", true); + DEFAULT( + "1", 0xFF121212, false, sizes(18f, 23f, 28f), + 0xFFFFFFFF, 0xFFCCCCCC, 0xFFCCCCCC, 0xFFCCCCCC, + null, false, false, false, true, true + ), + CLASSIC( + "2", 0xFFF5ECD9, true, sizes(15f, 20f, 25f), + 0xFF000000, 0xFF444444, 0xFFAA8844, 0xFF008800, + "fonts/AnkeHand.ttf", false, true, false, false, false + ), + ANDROID( + "3", 0xFF1A1A1A, false, sizes(21f, 26f, 31f), + 0xFFFFFF66, 0xFFCCCCCC, 0xFFEECC88, 0xFF66FF66, + "fonts/Crysta.ttf", true, false, true, false, true + ); + + /** Text size for the "fontsize" setting (0..3; out of range = medium). */ + fun textSizeSp(fontSize: Int): Float = textSizesSp.getOrElse(fontSize) { textSizesSp[2] } companion object { - fun fromName(name: String?): ListTheme = - entries.firstOrNull { it.name == name } ?: DEFAULT + fun fromName(name: String?): ListTheme { + // Legacy style names look like "org.openintents.shopping:style/Theme.ShoppingList.Classic". + val style = name?.replace('.', '_').orEmpty() + return when { + name == CLASSIC.storedValue || name == CLASSIC.name || + style.endsWith(":style/Theme_ShoppingList_Classic") -> CLASSIC + name == ANDROID.storedValue || name == ANDROID.name || + style.endsWith(":style/Theme_ShoppingList_Android") -> ANDROID + else -> DEFAULT + } + } } } + +/** Tiny is derived from small, like the legacy UI did (12/18). */ +private fun sizes(small: Float, medium: Float, large: Float) = + listOf(small * 12f / 18f, small, medium, large) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/Models.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/Models.kt index 6654619b..e2e7170c 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/Models.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/Models.kt @@ -44,3 +44,22 @@ data class ItemEdit( val tags: String?, val note: String? = null, ) + +/** An item sent by another app; [quantity] and [price] (text, e.g. "1.50") are optional. */ +data class NewItem( + val name: String, + val quantity: String? = null, + val price: String? = null, + val barcode: String? = null, +) + +/** + * The per-list filters (shared with the legacy UI): only items at [storeId] + * (when the "use_filters" setting is on) and/or with [tag]. Null = no filter. + */ +data class ListFilters(val storeId: Long? = null, val tag: String? = null) { + val isActive: Boolean get() = storeId != null || tag != null +} + +/** A list row's previous state, to undo a bulk action (mark all, clean up). */ +data class ItemSnapshot(val containsId: Long, val status: Long, val quantity: String?) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ProductLookup.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ProductLookup.kt new file mode 100644 index 00000000..2a2b43e7 --- /dev/null +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ProductLookup.kt @@ -0,0 +1,82 @@ +package org.openintents.shopping.data + +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.Locale + +/** Result of looking up a barcode online. */ +sealed interface LookupResult { + data class Found(val name: String) : LookupResult + /** The service answered but doesn't know the product. */ + data object NotFound : LookupResult + /** No connection, or the service didn't answer in time. */ + data object Offline : LookupResult +} + +/** Finds a product name for a barcode (EAN/UPC). */ +fun interface ProductLookup { + fun lookup(barcode: String): LookupResult +} + +/** + * [ProductLookup] using Open Food Facts (open data, https://world.openfoodfacts.org). + * Only the barcode is sent. Uses no library: HttpURLConnection + org.json. + */ +class OpenFoodFactsLookup(private val userAgent: String) : ProductLookup { + + override fun lookup(barcode: String): LookupResult { + val code = barcode.trim() + if (!isBarcode(code)) return LookupResult.NotFound + val lang = Locale.getDefault().language + val url = URL( + "https://world.openfoodfacts.org/api/v2/product/$code" + + "?fields=product_name,product_name_$lang,generic_name,brands" + ) + return try { + val connection = url.openConnection() as HttpURLConnection + try { + // Short timeouts: the user is waiting with the product in the hand. + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + // Open Food Facts asks apps to identify themselves. + connection.setRequestProperty("User-Agent", userAgent) + when (connection.responseCode) { + HttpURLConnection.HTTP_OK -> { + val body = connection.inputStream.bufferedReader().use { it.readText() } + parseProductName(body, lang)?.let { LookupResult.Found(it) } ?: LookupResult.NotFound + } + HttpURLConnection.HTTP_NOT_FOUND -> LookupResult.NotFound + else -> LookupResult.Offline // server error, rate limit, captive portal... + } + } finally { + connection.disconnect() + } + } catch (e: java.io.IOException) { + LookupResult.Offline // no network, DNS failure, timeout + } + } + + companion object { + private const val TIMEOUT_MS = 3000 + /** EAN-8, UPC-A, EAN-13 or GTIN-14: digits only. */ + fun isBarcode(text: String): Boolean = text.length in 8..14 && text.all { it in '0'..'9' } + + /** The product name from an Open Food Facts API v2 response, or null. */ + fun parseProductName(json: String, lang: String): String? { + val root = try { + JSONObject(json) + } catch (e: org.json.JSONException) { + return null + } + if (root.optInt("status", 0) != 1) return null + val product = root.optJSONObject("product") ?: return null + val name = listOf("product_name_$lang", "product_name", "generic_name") + .map { product.optString(it).trim() } + .firstOrNull { it.isNotEmpty() } ?: return null + // Add the brand unless the name already contains it ("Nutella (Ferrero)"). + val brand = product.optString("brands").split(',').firstOrNull()?.trim().orEmpty() + return if (brand.isEmpty() || name.contains(brand, ignoreCase = true)) name else "$name ($brand)" + } + } +} diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ProviderShoppingRepository.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ProviderShoppingRepository.kt index ce8d0379..1f093654 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/ProviderShoppingRepository.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ProviderShoppingRepository.kt @@ -11,7 +11,9 @@ import org.openintents.shopping.library.provider.ShoppingContract.Items import org.openintents.shopping.library.provider.ShoppingContract.Lists import org.openintents.shopping.library.provider.ShoppingContract.Status import org.openintents.shopping.library.provider.ShoppingContract.Stores +import org.openintents.shopping.library.util.PriceConverter import org.openintents.shopping.library.util.ShoppingUtils +import org.openintents.shopping.ui.PreferenceActivity /** * [ShoppingRepository] backed by the app's ContentProvider + SQLite. @@ -20,13 +22,18 @@ import org.openintents.shopping.library.util.ShoppingUtils */ class ProviderShoppingRepository(private val context: Context) : ShoppingRepository { + private companion object { + /** Any mode other than MODE_IN_SHOP selects the Pick items sort order. */ + const val MODE_PICK_ITEMS = 2 + } + private val resolver get() = context.contentResolver override fun getLists(): List { val out = ArrayList() resolver.query( Lists.CONTENT_URI, arrayOf(Lists._ID, Lists.NAME), - null, null, Lists.DEFAULT_SORT_ORDER + null, null, PreferenceActivity.getShoppingListSortOrderFromPrefs(context) )?.use { c -> while (c.moveToNext()) { out.add(ShoppingListInfo(c.getLong(0), c.getString(1) ?: "")) @@ -39,10 +46,20 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit // A fresh install has an empty lists table; create the default list // ("My shopping list"), mirroring the legacy ShoppingActivity. Without // this, items get added to a non-existent list and never display. - if (getLists().isEmpty()) { + val lists = getLists() + if (lists.isEmpty()) { return ShoppingUtils.getList(context, context.getString(R.string.my_shopping_list)) } - return ShoppingUtils.getDefaultList(context) + // The last-used list may have been deleted; fall back to the first list. + val id = ShoppingUtils.getDefaultList(context) + return if (lists.any { it.id == id }) id else lists.first().id + } + + override fun setActiveList(listId: Long) { + if (listId < 0) return + // Same file + key the legacy UI and the provider's ACTIVELIST query use. + context.getSharedPreferences("org.openintents.shopping_preferences", Context.MODE_PRIVATE) + .edit().putInt(PreferenceActivity.PREFS_LASTUSED, listId.toInt()).apply() } override fun getItems(listId: Long): List = @@ -51,7 +68,25 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit override fun getAllListItems(listId: Long): List = queryListItems(listId, includeRemoved = true) + // The stored values are indices into Contains.SORT_ORDERS. + override fun getSortOrder(listId: Long): Int = + PreferenceActivity.getSortOrderIndexFromPrefs(context, PreferenceActivity.MODE_IN_SHOP, listId) + + override fun setSortOrder(listId: Long, sortOrder: Int) { + if (PreferenceActivity.getUsingPerListSortFromPrefs(context)) { + val values = ContentValues().apply { put(Lists.ITEMS_SORT, sortOrder) } + resolver.update(Uri.withAppendedPath(Lists.CONTENT_URI, listId.toString()), values, null, null) + } else { + @Suppress("DEPRECATION") + android.preference.PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PreferenceActivity.PREFS_SORTORDER, sortOrder.toString()).apply() + } + } + private fun queryListItems(listId: Long, includeRemoved: Boolean): List { + // Same sort orders as the legacy UI (Pick items can have its own). + val mode = if (includeRemoved) MODE_PICK_ITEMS else PreferenceActivity.MODE_IN_SHOP + val sortOrder = PreferenceActivity.getSortOrderFromPrefs(context, mode, listId) val out = ArrayList() resolver.query( ContainsFull.CONTENT_URI, @@ -61,7 +96,7 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit ContainsFull.PRIORITY, ContainsFull.ITEM_TAGS, ContainsFull.ITEM_UNITS ), ContainsFull.LIST_ID + " = ?", arrayOf(listId.toString()), - ContainsFull.DEFAULT_SORT_ORDER + sortOrder )?.use { c -> while (c.moveToNext()) { if (!includeRemoved && c.getLong(3) == Status.REMOVED_FROM_LIST) continue @@ -83,15 +118,27 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit return out } - override fun addItem(listId: Long, name: String): Long { - val trimmed = name.trim() + override fun addItem(listId: Long, name: String): Long = addItem(listId, NewItem(name)) + + override fun addItems(listId: Long, items: List): Int = + items.count { addItem(listId, it) >= 0 } + + private fun addItem(listId: Long, item: NewItem): Long { + val trimmed = item.name.trim() if (trimmed.isEmpty()) return -1L + // Like the legacy UI: reuse a catalogue item of the same name (keeping its + // price, tags and store prices) unless the user limited that to this list. + val scope = if (PreferenceActivity.getCompleteFromCurrentListOnlyFromPrefs(context)) { + listId.toString() + } else null + val price = item.price?.trim()?.takeIf { PriceConverter.getCentPriceFromString(it) != null } val itemId = ShoppingUtils.updateOrCreateItem( - context, trimmed, null, null, null, listId.toString() + context, trimmed, null, price, item.barcode?.trim()?.ifEmpty { null }, scope ) + if (itemId < 0) return -1L ShoppingUtils.addItemToList( context, itemId, listId, Status.WANT_TO_BUY, - null, null, false, false, false + null, item.quantity?.trim()?.ifEmpty { null }, false, false, false ) ShoppingUtils.addDefaultsToAddedItem(context, listId, itemId) return itemId @@ -120,7 +167,11 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit // Name, price, units and tags live on the item itself. val itemValues = ContentValues().apply { put(Items.NAME, edit.name.trim()) - if (edit.priceCents != null) put(Items.PRICE, edit.priceCents) else putNull(Items.PRICE) + // Only write the price when it was changed: with "per-store prices" the + // shown price is the cheapest store price, not the item's own price. + if (edit.priceCents != item.priceCents) { + if (edit.priceCents != null) put(Items.PRICE, edit.priceCents) else putNull(Items.PRICE) + } put(Items.UNITS, edit.units ?: "") put(Items.TAGS, edit.tags ?: "") put(Items.NOTE, edit.note ?: "") @@ -143,7 +194,84 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit override fun removeItem(listId: Long, item: ShoppingItem) { // Soft-remove: keep the relation row (status REMOVED_FROM_LIST) so the item // stays in the catalogue and can be re-added via Pick-items mode. - setItemStatus(item.containsId, Status.REMOVED_FROM_LIST) + val values = ContentValues().apply { + put(Contains.STATUS, Status.REMOVED_FROM_LIST) + // "Reset quantity" setting: a re-added item starts without the old quantity. + if (PreferenceActivity.getResetQuantity(context)) put(Contains.QUANTITY, "") + } + resolver.update(Uri.withAppendedPath(Contains.CONTENT_URI, item.containsId.toString()), values, null, null) + } + + override fun getItemStatus(containsId: Long): Long? = + resolver.query( + Uri.withAppendedPath(Contains.CONTENT_URI, containsId.toString()), + arrayOf(Contains.STATUS), null, null, null + )?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null } + + override fun getListFilters(listId: Long): ListFilters = + resolver.query( + Uri.withAppendedPath(Lists.CONTENT_URI, listId.toString()), + arrayOf(Lists.STORE_FILTER, Lists.TAGS_FILTER), null, null, null + )?.use { c -> + if (!c.moveToFirst()) ListFilters() + else ListFilters( + storeId = if (c.isNull(0)) null else c.getLong(0).takeIf { it >= 0 }, + tag = c.getString(1)?.takeIf { it.isNotBlank() }, + ) + } ?: ListFilters() + + override fun setStoreFilter(listId: Long, storeId: Long?) = + updateList(listId, ContentValues().apply { put(Lists.STORE_FILTER, storeId ?: -1L) }) + + override fun setTagFilter(listId: Long, tag: String?) = + updateList(listId, ContentValues().apply { put(Lists.TAGS_FILTER, tag ?: "") }) + + private fun updateList(listId: Long, values: ContentValues) { + resolver.update(Uri.withAppendedPath(Lists.CONTENT_URI, listId.toString()), values, null, null) + } + + override fun getItemNameForBarcode(barcode: String): String? = + resolver.query( + Items.CONTENT_URI, arrayOf(Items.NAME), "${Items.BARCODE} = ?", arrayOf(barcode), null + )?.use { c -> if (c.moveToFirst()) c.getString(0)?.takeIf { it.isNotBlank() } else null } + + override fun getListTags(listId: Long): List { + val tags = sortedSetOf(String.CASE_INSENSITIVE_ORDER) + resolver.query( + Uri.parse("content://org.openintents.shopping/listtags/$listId"), + arrayOf(ContainsFull.ITEM_TAGS), null, null, null + )?.use { c -> + while (c.moveToNext()) { + // Tags are comma separated. + c.getString(0)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.let(tags::addAll) + } + } + return tags.toList() + } + + override fun moveItem(item: ShoppingItem, targetListId: Long) { + val values = ContentValues().apply { put(Contains.LIST_ID, targetListId) } + resolver.update(Uri.withAppendedPath(Contains.CONTENT_URI, item.containsId.toString()), values, null, null) + } + + override fun copyItem(item: ShoppingItem): Long? = + resolver.query( + Uri.withAppendedPath(Uri.withAppendedPath(Contains.CONTENT_URI, "copyof"), item.containsId.toString()), + arrayOf("item_id", "contains_id"), null, null, null + )?.use { c -> if (c.moveToFirst()) c.getLong(1) else null } + + override fun deleteItem(listId: Long, item: ShoppingItem) { + ShoppingUtils.deleteItem(context, item.itemId.toString(), listId.toString()) + } + + override fun restore(snapshots: List) { + snapshots.forEach { snap -> + val values = ContentValues().apply { + put(Contains.STATUS, snap.status) + put(Contains.QUANTITY, snap.quantity ?: "") + } + resolver.update(Uri.withAppendedPath(Contains.CONTENT_URI, snap.containsId.toString()), values, null, null) + } } override fun setItemStatus(containsId: Long, status: Long) { @@ -165,7 +293,7 @@ class ProviderShoppingRepository(private val context: Context) : ShoppingReposit } override fun setListTheme(listId: Long, theme: ListTheme) { - val values = ContentValues().apply { put(Lists.SKIN_BACKGROUND, theme.name) } + val values = ContentValues().apply { put(Lists.SKIN_BACKGROUND, theme.storedValue) } resolver.update(Uri.withAppendedPath(Lists.CONTENT_URI, listId.toString()), values, null, null) } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingRepository.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingRepository.kt index 3bf4191b..3e211aae 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingRepository.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingRepository.kt @@ -11,13 +11,26 @@ import org.openintents.shopping.library.provider.ShoppingContract.Status */ interface ShoppingRepository { - /** All shopping lists, in the provider's default order. */ + /** + * The sort order of [listId]: one of the legacy sort order values + * (res/values/strings_not_for_translation.xml preference_sortorder_entryvalues). + * Per list when "perListSort" is on, else the global "sortorder" setting. + */ + fun getSortOrder(listId: Long): Int + + /** Sets the sort order (for this list with "perListSort", else globally). */ + fun setSortOrder(listId: Long, sortOrder: Int) + + /** All shopping lists, in the order chosen in the settings. */ fun getLists(): List /** The id of the list to show by default (creates the initial list if needed). */ fun getDefaultListId(): Long - /** The items currently on [listId] (excludes items removed from the list). */ + /** Remembers [listId] as the list to open next time. */ + fun setActiveList(listId: Long) + + /** The items currently on [listId] (excludes items removed from the list), sorted per [getSortOrder]. */ fun getItems(listId: Long): List /** @@ -29,6 +42,44 @@ interface ShoppingRepository { /** Adds (or reuses) an item by name on [listId] as "want to buy". Returns item id, or -1 for blank. */ fun addItem(listId: Long, name: String): Long + /** + * Adds items sent by another app (shared text, INSERT_FROM_EXTRAS). Quantity + * and price are optional and may be null. Returns the number of items added. + */ + fun addItems(listId: Long, items: List): Int + + /** The filters set on [listId]. */ + fun getListFilters(listId: Long): ListFilters + + /** Only show items stocked at [storeId] (null = all stores; needs "use_filters"). */ + fun setStoreFilter(listId: Long, storeId: Long?) + + /** Only show items tagged [tag] (null = all). */ + fun setTagFilter(listId: Long, tag: String?) + + /** The name of the catalogue item with this barcode, or null. */ + fun getItemNameForBarcode(barcode: String): String? + + /** The distinct tags of the items on [listId], sorted. */ + fun getListTags(listId: Long): List + + /** Moves an item (its row, with quantity/priority/status) to another list. */ + fun moveItem(item: ShoppingItem, targetListId: Long) + + /** Copies an item (a new catalogue item on the same list). Returns the new row id, or null. */ + fun copyItem(item: ShoppingItem): Long? + + /** + * Deletes an item from [listId] for good, and from the catalogue if no other + * list has it (with its store prices). + */ + fun deleteItem(listId: Long, item: ShoppingItem) + + /** Restores rows to a previous state (undo of mark all / clean up). */ + fun restore(snapshots: List) { + snapshots.forEach { setItemStatus(it.containsId, it.status) } + } + /** * Distinct item names from the whole catalogue (every list), sorted, for the * add-field auto-suggestions. Items on the current list are a subset of these. @@ -52,17 +103,25 @@ interface ShoppingRepository { setItemStatus(item.containsId, if (onList) Status.WANT_TO_BUY else Status.REMOVED_FROM_LIST) } - /** Flips an item between WANT_TO_BUY and BOUGHT. */ - fun toggleItemBought(item: ShoppingItem) { - val newStatus = if (item.status == Status.BOUGHT) Status.WANT_TO_BUY else Status.BOUGHT + /** + * Flips an item between WANT_TO_BUY and BOUGHT, based on its current stored + * status (so two quick taps flip it twice). Returns the new status. + */ + fun toggleItemBought(item: ShoppingItem): Long { + val current = getItemStatus(item.containsId) ?: item.status + val newStatus = if (current == Status.BOUGHT) Status.WANT_TO_BUY else Status.BOUGHT setItemStatus(item.containsId, newStatus) + return newStatus } - /** Removes every bought item from [listId] (marks them removed-from-list). Returns the count. */ - fun cleanupList(listId: Long): Int { + /** The stored status of a relation row, or null if it does not exist. */ + fun getItemStatus(containsId: Long): Long? + + /** Removes every bought item from [listId]. Returns their previous state (for undo). */ + fun cleanupList(listId: Long): List { val bought = getItems(listId).filter { it.status == Status.BOUGHT } - bought.forEach { setItemStatus(it.containsId, Status.REMOVED_FROM_LIST) } - return bought.size + bought.forEach { removeItem(listId, it) } + return bought.map { ItemSnapshot(it.containsId, it.status, it.quantity) } } /** Creates a list by name, or returns the id of the existing list with that name. */ @@ -80,10 +139,15 @@ interface ShoppingRepository { /** Deletes a list (and its items' membership). */ fun deleteList(listId: Long) - /** Marks every item on [listId] as bought (true) or want-to-buy (false). */ - fun markAllItems(listId: Long, bought: Boolean) { + /** + * Marks every item on [listId] as bought (true) or want-to-buy (false). + * Returns the previous state of the changed items (for undo). + */ + fun markAllItems(listId: Long, bought: Boolean): List { val target = if (bought) Status.BOUGHT else Status.WANT_TO_BUY - getItems(listId).forEach { if (it.status != target) setItemStatus(it.containsId, target) } + val changed = getItems(listId).filter { it.status != target } + changed.forEach { setItemStatus(it.containsId, target) } + return changed.map { ItemSnapshot(it.containsId, it.status, it.quantity) } } /** The stores defined for [listId]. */ diff --git a/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingTotals.kt b/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingTotals.kt index f2e83a35..bd033614 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingTotals.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/data/ShoppingTotals.kt @@ -23,9 +23,7 @@ fun computeTotals(items: List): ListTotals { var toBuy = 0L var bought = 0L for (item in items) { - val unit = item.priceCents ?: continue - val qty = item.quantity?.trim()?.toDoubleOrNull() ?: 1.0 - val line = (unit * qty).roundToLong() + val line = lineCents(item) ?: continue when (item.status) { Status.BOUGHT -> bought += line Status.WANT_TO_BUY -> toBuy += line @@ -34,3 +32,23 @@ fun computeTotals(items: List): ListTotals { } return ListTotals(toBuy, bought) } + +/** An item's line cost in cents (unit price * quantity), or null if it has no price. */ +fun lineCents(item: ShoppingItem): Long? { + val unit = item.priceCents ?: return null + val qty = item.quantity?.trim()?.toDoubleOrNull() ?: 1.0 + return (unit * qty).roundToLong() +} + +/** + * The legacy "subtotal by priority": the cost of the items with a priority from + * 1 to [threshold] (0 = not shown). Bought items count when [includesChecked]. + */ +fun prioritySubtotal(items: List, threshold: Int, includesChecked: Boolean): Long { + if (threshold <= 0) return 0L + return items.sumOf { item -> + val prio = item.priority?.trim()?.toIntOrNull() ?: 0 + val counts = item.status == Status.WANT_TO_BUY || (includesChecked && item.status == Status.BOUGHT) + if (counts && prio in 1..threshold) lineCents(item) ?: 0L else 0L + } +} diff --git a/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingDatabase.kt b/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingDatabase.kt index 52169d8f..1d4184b2 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingDatabase.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingDatabase.kt @@ -31,6 +31,12 @@ import org.openintents.shopping.library.provider.ShoppingContract.Lists internal class ShoppingDatabase(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + init { + // ShoppingBackupAgent backs up only the main database file, so keep + // all data in it (no -wal file; WAL is on by default since API 28). + setWriteAheadLoggingEnabled(false) + } + /** * Creates tables "items", "lists", and "contains". */ diff --git a/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingProvider.kt b/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingProvider.kt index 656eb547..702e0464 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingProvider.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/provider/ShoppingProvider.kt @@ -44,7 +44,6 @@ import org.openintents.shopping.library.provider.ShoppingContract.Status import org.openintents.shopping.library.provider.ShoppingContract.Stores import org.openintents.shopping.library.provider.ShoppingContract.Units import org.openintents.shopping.ui.PreferenceActivity -import org.openintents.shopping.ui.widget.ShoppingItemsView /** * Provides access to a database of shopping items and shopping lists. @@ -117,13 +116,14 @@ class ShoppingProvider : ContentProvider() { CONTAINS_FULL -> { val inSearchMode = appIsInSearchMode() + val listIdArg = selectionArgs?.getOrNull(0)?.toLongOrNull() ?: -1L // all callers pass list id as selection_args[0]. perhaps not so // nice to depend on that, but... need to choose the projection map // based on the list's store filter. if (!inSearchMode && PreferenceActivity.getUsingFiltersFromPrefs(context!!) - && listUsesStoreFilter(selectionArgs!![0]!!) + && listUsesStoreFilter(listIdArg) ) { // actually there are two ways we could do the query when // filtering by stores. perhaps @@ -171,7 +171,7 @@ class ShoppingProvider : ContentProvider() { ) } defaultOrderBy = ContainsFull.DEFAULT_SORT_ORDER - val tagFilter = getListTagsFilter(selectionArgs!![0]!!) + val tagFilter = getListTagsFilter(listIdArg) if (!inSearchMode && !TextUtils.isEmpty(tagFilter)) { qb.appendWhere(" AND items.tags like '%" + escapeSQLChars(tagFilter!!) + "%' ESCAPE '`'") } @@ -179,7 +179,7 @@ class ShoppingProvider : ContentProvider() { CONTAINS_FULL_ID -> { qb.tables = "contains, items, lists" - qb.appendWhere("_id=" + url.pathSegments[1]) + qb.appendWhere("contains._id=" + url.pathSegments[1] + " AND ") qb.appendWhere( "contains.item_id = items._id AND " + "contains.list_id = lists._id" @@ -233,7 +233,7 @@ class ShoppingProvider : ContentProvider() { ITEMSTORES_ID -> { qb.tables = "itemstores, items, stores" - qb.appendWhere("_id=" + url.pathSegments[1]) + qb.appendWhere("itemstores._id=" + url.pathSegments[1] + " AND ") qb.appendWhere("itemstores.item_id = items._id AND itemstores.store_id = stores._id") } @@ -287,7 +287,7 @@ class ShoppingProvider : ContentProvider() { // assumes only one projection will ever be used, // asking only for the id of the active list. val sortOrder = PreferenceActivity.getSortOrderFromPrefs( - context!!, ShoppingItemsView.MODE_IN_SHOP + context!!, PreferenceActivity.MODE_IN_SHOP ) m.addRow(arrayOf(sortOrder)) return m @@ -395,7 +395,7 @@ class ShoppingProvider : ContentProvider() { return c } - private fun listUsesStoreFilter(listId: String): Boolean { + private fun listUsesStoreFilter(listId: Long): Boolean { val db = mOpenHelper!!.readableDatabase val qb = SQLiteQueryBuilder() qb.tables = "lists" @@ -405,6 +405,7 @@ class ShoppingProvider : ContentProvider() { null, null, null, null ) if (c.count != 1) { + c.close() return false } @@ -416,7 +417,7 @@ class ShoppingProvider : ContentProvider() { return (storeId != -1L) } - private fun getListTagsFilter(listId: String): String? { + private fun getListTagsFilter(listId: Long): String? { val db = mOpenHelper!!.readableDatabase val qb = SQLiteQueryBuilder() qb.tables = "lists" @@ -426,6 +427,7 @@ class ShoppingProvider : ContentProvider() { null, null, null ) if (c.count != 1) { + c.close() return null } @@ -462,6 +464,7 @@ class ShoppingProvider : ContentProvider() { null, null, null ) if (c.count != 1) { + c.close() return null } @@ -476,6 +479,7 @@ class ShoppingProvider : ContentProvider() { qb.appendWhere("_id=$oldItemId") c = qb.query(db, Items.PROJECTION_TO_COPY, null, null, null, null, null) if (c.count != 1) { + c.close() return null } c.moveToFirst() @@ -493,6 +497,7 @@ class ShoppingProvider : ContentProvider() { null ) if (c.count != 1) { + c.close() return null } c.moveToFirst() @@ -608,7 +613,7 @@ class ShoppingProvider : ContentProvider() { val rowID: Long val now = java.lang.Long.valueOf(System.currentTimeMillis()) - val r = android.content.res.Resources.getSystem() + val r = context!!.resources // Make sure that the fields are all set if (!values.containsKey(Lists.NAME)) { diff --git a/ShoppingList/src/main/java/org/openintents/shopping/share/ListShareSettingsActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/share/ListShareSettingsActivity.kt index aa44f116..c63931fc 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/share/ListShareSettingsActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/share/ListShareSettingsActivity.kt @@ -57,7 +57,10 @@ class ListShareSettingsActivity : Activity() { setContentView(R.layout.activity_list_share_settings) // Get the uri of the list - mUri = intent.data!! + mUri = intent.data ?: run { + finish() + return + } // Get a cursor to access the note mCursor = managedQuery(mUri, mProjectionLists, null, null, null) @@ -86,8 +89,7 @@ class ListShareSettingsActivity : Activity() { super.onResume() // Initialize the text with the title column from the cursor - if (mCursor != null) { - mCursor!!.moveToFirst() + if (mCursor != null && mCursor!!.moveToFirst()) { val sn = mCursor!!.getString(mProjectionListsSHARENAME) mShareName.setText(sn) val contacts = mCursor!!.getString(mProjectionListsSHARECONTACTS) @@ -113,7 +115,7 @@ class ListShareSettingsActivity : Activity() { } // Write the text back into the cursor - if (mCursor != null) { + if (mCursor != null && mCursor!!.moveToFirst()) { val values = ContentValues() values.put(Lists.SHARE_NAME, sharename) values.put(Lists.SHARE_CONTACTS, contacts) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/AddLocationAlertActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/AddLocationAlertActivity.kt index cf47c310..0d3fecb3 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/AddLocationAlertActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/AddLocationAlertActivity.kt @@ -53,6 +53,7 @@ open class AddLocationAlertActivity : Activity(), OnClickListener { super.onCreate(icicle) mTag = Tag(this) + Alert.init(applicationContext) setContentView(R.layout.activity_add_location_alert) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/ItemStoresActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/ItemStoresActivity.kt index d083f247..587bf3e1 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/ItemStoresActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/ItemStoresActivity.kt @@ -37,13 +37,21 @@ open class ItemStoresActivity : Activity() { private var mItemId: Long = 0 private lateinit var mItemStores: StoreListView - private var mSelectedStorePosition: Int = 0 + // Selected store is remembered by id (and saved across recreation), + // not by list position. + private var mSelectedStoreId: String? = null + private var mSelectedStoreName: String = "" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_itemstores) + if (savedInstanceState != null) { + mSelectedStoreId = savedInstanceState.getString(BUNDLE_SELECTED_STORE_ID) + mSelectedStoreName = savedInstanceState.getString(BUNDLE_SELECTED_STORE_NAME) ?: "" + } + mItemStores = findViewById(R.id.list_stores) mItemStores.setOnCreateContextMenuListener(object : View.OnCreateContextMenuListener { @@ -83,6 +91,20 @@ open class ItemStoresActivity : Activity() { }) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(BUNDLE_SELECTED_STORE_ID, mSelectedStoreId) + outState.putString(BUNDLE_SELECTED_STORE_NAME, mSelectedStoreName) + } + + override fun onDestroy() { + // unregisters the content observer registered in fillItems() + if (::mItemStores.isInitialized) { + mItemStores.onPause() + } + super.onDestroy() + } + override fun onCreateDialog(id: Int): Dialog? { return when (id) { DIALOG_NEW_STORE -> @@ -117,7 +139,8 @@ open class ItemStoresActivity : Activity() { override fun onContextItemSelected(item: MenuItem): Boolean { val menuInfo = item.menuInfo as AdapterView.AdapterContextMenuInfo - mSelectedStorePosition = menuInfo.position + mSelectedStoreId = mItemStores.getStoreId(menuInfo.position) + mSelectedStoreName = mItemStores.getStoreName(menuInfo.position) when (item.itemId) { MENU_RENAME_STORE -> { @@ -131,7 +154,7 @@ open class ItemStoresActivity : Activity() { } private fun getSelectedStoreName(): String { - return mItemStores.getStoreName(mSelectedStorePosition) + return mSelectedStoreName } private fun createStore(name: String) { @@ -152,7 +175,7 @@ open class ItemStoresActivity : Activity() { return } - val storeId = mItemStores.getStoreId(mSelectedStorePosition) + val storeId = mSelectedStoreId ?: return val values = ContentValues() values.put(Stores.NAME, newName) contentResolver.update( @@ -192,8 +215,8 @@ open class ItemStoresActivity : Activity() { * Deletes currently selected store. */ private fun deleteStore() { - val storeId = mItemStores.getStoreId(mSelectedStorePosition) - ShoppingUtils.deleteStore(this, storeId!!) + val storeId = mSelectedStoreId ?: return + ShoppingUtils.deleteStore(this, storeId) mItemStores.requery() } @@ -226,5 +249,7 @@ open class ItemStoresActivity : Activity() { val MENU_DELETE_STORE: Int = Menu.FIRST + 1 private const val DIALOG_NEW_STORE = 1 private const val DIALOG_RENAME_STORE = 2 + private const val BUNDLE_SELECTED_STORE_ID = "selected_store_id" + private const val BUNDLE_SELECTED_STORE_NAME = "selected_store_name" } } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/LayoutChoiceActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/LayoutChoiceActivity.kt deleted file mode 100644 index ef1380d4..00000000 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/LayoutChoiceActivity.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.openintents.shopping.ui - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import androidx.annotation.IdRes -import androidx.appcompat.app.AppCompatActivity -import android.view.MenuItem -import android.view.View -import android.widget.RadioGroup -import org.openintents.shopping.R - -class LayoutChoiceActivity : AppCompatActivity(), RadioGroup.OnCheckedChangeListener { - - companion object { - @JvmStatic - fun show(context: Activity): Boolean { - return if (PreferenceActivity.getShowLayoutChoice(context) && PreferenceActivity.getUsingHoloSearchFromPrefs(context)) { - context.startActivity(Intent(context, LayoutChoiceActivity::class.java)) - true - } else { - false - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.dialog_layout_choice) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - - val radioGroup = findViewById(R.id.layout_choice) - - if (PreferenceActivity.getUsingHoloSearchFromPrefs(this)) { - radioGroup.check(R.id.layout_choice_actionbar) - } else { - radioGroup.check(R.id.layout_choice_bottom) - } - radioGroup.setOnCheckedChangeListener(this) - - findViewById(R.id.image_actionbar).setOnClickListener { - radioGroup.check(R.id.layout_choice_actionbar) - } - findViewById(R.id.image_bottom).setOnClickListener { - radioGroup.check(R.id.layout_choice_bottom) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == android.R.id.home) { - finish() - true - } else { - super.onOptionsItemSelected(item) - } - } - - override fun onCheckedChanged(group: RadioGroup, @IdRes checkedId: Int) { - PreferenceActivity.setUsingHoloSearch(this, checkedId == R.id.layout_choice_actionbar) - PreferenceActivity.setShowLayoutChoice(this, false) - startActivity(Intent(this, org.openintents.shopping.ShoppingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) - finish() - } -} diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/PickItemsActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/PickItemsActivity.kt index 8620b68b..f3d82588 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/PickItemsActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/PickItemsActivity.kt @@ -15,6 +15,7 @@ class PickItemsActivity : Activity() { val listItems = findViewById(R.id.list_items) listItems.setPickItemsDlgMode() + listItems.initTotals() val listId = intent.data!!.lastPathSegment listItems.fillItems(this, listId!!.toLong()) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/PreferenceActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/PreferenceActivity.kt index 96de668c..9e65c7b1 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/PreferenceActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/PreferenceActivity.kt @@ -21,7 +21,6 @@ import org.openintents.shopping.R import org.openintents.shopping.library.provider.ShoppingContract.Contains import org.openintents.shopping.library.provider.ShoppingContract.Lists import org.openintents.shopping.library.util.ShoppingUtils -import org.openintents.shopping.ui.widget.ShoppingItemsView import org.openintents.util.BackupManagerWrapper import org.openintents.util.IntentUtils @@ -35,6 +34,8 @@ class PreferenceActivity : android.preference.PreferenceActivity(), companion object { const val PREFS_SAMESORTFORPICK = "samesortforpick" const val PREFS_SAMESORTFORPICK_DEFAULT = false + /** Sort mode for the shopping list (other values: Pick items). */ + const val MODE_IN_SHOP = 1 const val PREFS_SORTORDER = "sortorder" const val PREFS_PICKITEMS_SORTORDER = "sortorderForPickItems" const val PREFS_SORTORDER_DEFAULT = "3" @@ -204,17 +205,17 @@ class PreferenceActivity : android.preference.PreferenceActivity(), var sortOrder = 0 var effectiveMode = mode - if (effectiveMode != ShoppingItemsView.MODE_IN_SHOP) { + if (effectiveMode != MODE_IN_SHOP) { val followShopping = PreferenceManager .getDefaultSharedPreferences(context).getBoolean( PREFS_SAMESORTFORPICK, PREFS_SAMESORTFORPICK_DEFAULT) if (followShopping) { - effectiveMode = ShoppingItemsView.MODE_IN_SHOP + effectiveMode = MODE_IN_SHOP } } - if (effectiveMode != ShoppingItemsView.MODE_IN_SHOP) { + if (effectiveMode != MODE_IN_SHOP) { // use the pick-items-specific value, if there is one try { sortOrder = Integer.parseInt(PreferenceManager @@ -225,11 +226,11 @@ class PreferenceActivity : android.preference.PreferenceActivity(), // Guess somebody messed with the preferences and put a string // into // this field. We'll follow shopping mode then. - effectiveMode = ShoppingItemsView.MODE_IN_SHOP + effectiveMode = MODE_IN_SHOP } } - if (effectiveMode == ShoppingItemsView.MODE_IN_SHOP) { + if (effectiveMode == MODE_IN_SHOP) { var set = false if (getUsingPerListSortFromPrefs(context)) { @@ -289,7 +290,7 @@ class PreferenceActivity : android.preference.PreferenceActivity(), fun prefsStatusAffectsSort(context: Context, mode: Int): Boolean { val index = getSortOrderIndexFromPrefs(context, mode) var affects = Contains.StatusAffectsSortOrder[index] - if (mode == ShoppingItemsView.MODE_IN_SHOP && !affects) { + if (mode == MODE_IN_SHOP && !affects) { // in shopping mode we should also invalidate display when // marking items if we are hiding checked items. affects = getHideCheckedItemsFromPrefs(context) @@ -482,11 +483,6 @@ class PreferenceActivity : android.preference.PreferenceActivity(), mIncludesChecked = findPreference(PREFS_PRIOSUBINCLCHECKED) as CheckBoxPreference - val layoutChoicePreference = findPreference("layout_choice") - layoutChoicePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> - startActivity(Intent(this@PreferenceActivity, LayoutChoiceActivity::class.java)) - true - } val shared = preferenceScreen.sharedPreferences updatePrioSubtotalSummary(shared) updatePickItemsSortPref(shared) diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingActivity.java b/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingActivity.java index 74f34654..f091e1db 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingActivity.java +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingActivity.java @@ -254,6 +254,9 @@ public class ShoppingActivity extends DistributionLibraryFragmentActivity private static final String BUNDLE_ITEM_URI = "item uri"; private static final String BUNDLE_RELATION_URI = "relation_uri"; private static final String BUNDLE_MODE = "mode"; + private static final String BUNDLE_MOVE_CONTAINS_ID = "move_contains_id"; + private static final String BUNDLE_DELETE_ITEM_ID = "delete_item_id"; + private static final String BUNDLE_DELETE_ITEM_NAME = "delete_item_name"; // private Cursor mCursorItems; private static final String BUNDLE_MODE_BEFORE_SEARCH = "mode_before_search"; @@ -347,12 +350,13 @@ public void onShake() { */ private boolean mUseSensor; private Uri mRelationUri; - private int mMoveItemPosition; + private String mMoveContainsId; private EditItemDialog.FieldType mEditItemFocusField = EditItemDialog.FieldType.ITEMNAME; private GestureDetector mGestureDetector; private View.OnTouchListener mGestureListener; - private int mDeleteItemPosition; + private String mDeleteItemId; + private String mDeleteItemName; // Handle the process of automatically updating enabled sensors: private Handler mHandler = new Handler() { @Override @@ -403,9 +407,6 @@ public void onCreate(Bundle icicle) { return; } - if (LayoutChoiceActivity.show(this)) { - finish(); - } setContentView(R.layout.activity_shopping); // mEditItemPosition = -1; @@ -521,6 +522,9 @@ public void onCreate(Bundle icicle) { mRelationUri = Uri.parse(icicle.getString(BUNDLE_RELATION_URI)); } mItemsView.setModes(icicle.getInt(BUNDLE_MODE), icicle.getInt(BUNDLE_MODE_BEFORE_SEARCH)); + mMoveContainsId = icicle.getString(BUNDLE_MOVE_CONTAINS_ID); + mDeleteItemId = icicle.getString(BUNDLE_DELETE_ITEM_ID); + mDeleteItemName = icicle.getString(BUNDLE_DELETE_ITEM_NAME); } // set focus to the edit line: @@ -895,6 +899,9 @@ protected void onSaveInstanceState(Bundle outState) { final int saveMode = mItemsView.getInSearch() ? mItemsView.mModeBeforeSearch : mItemsView.getMode(); outState.putInt(BUNDLE_MODE, saveMode); outState.putInt(BUNDLE_MODE_BEFORE_SEARCH, saveMode); + outState.putString(BUNDLE_MOVE_CONTAINS_ID, mMoveContainsId); + outState.putString(BUNDLE_DELETE_ITEM_ID, mDeleteItemId); + outState.putString(BUNDLE_DELETE_ITEM_NAME, mDeleteItemName); mUpdating = false; // after items have been added through an "insert from extras" the @@ -1442,7 +1449,9 @@ public void onItemSelected(CharSequence name, int id) { default: break; } - mItemsView.mCursorItems.moveToPosition(pos); + if (!moveItemsCursorTo(pos)) { + return; + } String containsId = mItemsView.mCursorItems .getString(mStringItemsCONTAINSID); Uri uri = Uri.withAppendedPath( @@ -1872,11 +1881,16 @@ public boolean onContextItemSelected(MenuItem item) { deleteItemDialog(menuInfo.position); break; case MENU_MOVE_ITEM: + Cursor moveCursor = mItemsView.mCursorItems; + if (moveCursor == null || moveCursor.isClosed() + || !moveCursor.moveToPosition(menuInfo.position)) { + break; + } + mMoveContainsId = moveCursor.getString(mStringItemsCONTAINSID); Intent intent = new Intent(); intent.setAction(Intent.ACTION_PICK); intent.setData(ShoppingContract.Lists.CONTENT_URI); startActivityForResult(intent, REQUEST_PICK_LIST); - mMoveItemPosition = menuInfo.position; break; case MENU_COPY_ITEM: copyItem(menuInfo.position); @@ -2149,7 +2163,9 @@ void editItem(int position, EditItemDialog.FieldType field) { if (debug) { Log.d(TAG, "EditItems: Position: " + position); } - mItemsView.mCursorItems.moveToPosition(position); + if (!moveItemsCursorTo(position)) { + return; + } // mEditItemPosition = position; long itemId = mItemsView.mCursorItems.getLong(mStringItemsITEMID); @@ -2164,7 +2180,9 @@ void editItemStores(int position) { Log.d(TAG, "EditItemStores: Position: " + position); } - mItemsView.mCursorItems.moveToPosition(position); + if (!moveItemsCursorTo(position)) { + return; + } // mEditItemPosition = position; long itemId = mItemsView.mCursorItems.getLong(mStringItemsITEMID); @@ -2182,21 +2200,34 @@ void deleteItemDialog(int position) { if (debug) { Log.d(TAG, "EditItems: Position: " + position); } - mItemsView.mCursorItems.moveToPosition(position); - mDeleteItemPosition = position; + if (!moveItemsCursorTo(position)) { + return; + } + mDeleteItemId = mItemsView.mCursorItems.getString(mStringItemsITEMID); + mDeleteItemName = mItemsView.mCursorItems.getString(mStringItemsITEMNAME); showDialog(DIALOG_DELETE_ITEM); } /** - * delete item + * Moves the items cursor to the given position. + * + * @return false if the cursor is not (yet) available, e.g. while the + * list is being reloaded. */ - void deleteItem(int position) { + private boolean moveItemsCursorTo(int position) { Cursor c = mItemsView.mCursorItems; - c.moveToPosition(position); + return c != null && !c.isClosed() && c.moveToPosition(position); + } + /** + * delete item + */ + void deleteItem(String itemId) { + if (itemId == null) { + return; + } String listId = mListUri.getLastPathSegment(); - String itemId = c.getString(mStringItemsITEMID); ShoppingUtils.deleteItem(this, itemId, listId); // c.requery(); @@ -2207,20 +2238,8 @@ void deleteItem(int position) { /** * move item */ - void moveItem(int position, int targetListId) { - Cursor c = mItemsView.mCursorItems; - mItemsView.mCursorItems.requery(); - c.moveToPosition(position); - - long listId = getSelectedListId(); - if (false && listId < 0) { - // No valid list - probably view is not active - // and no item is selected. - return; - } - + void moveItem(String containsId, int targetListId) { // Attach item to new list, preserving all other fields - String containsId = c.getString(mStringItemsCONTAINSID); ContentValues cv = new ContentValues(1); cv.put(Contains.LIST_ID, targetListId); getContentResolver().update( @@ -2234,9 +2253,10 @@ void moveItem(int position, int targetListId) { * copy item */ void copyItem(int position) { + if (!moveItemsCursorTo(position)) { + return; + } Cursor c = mItemsView.mCursorItems; - mItemsView.mCursorItems.requery(); - c.moveToPosition(position); String containsId = c.getString(mStringItemsCONTAINSID); Long newContainsId; Long newItemId; @@ -2267,8 +2287,10 @@ void copyItem(int position) { * removeItemFromList */ void removeItemFromList(int position) { + if (!moveItemsCursorTo(position)) { + return; + } Cursor c = mItemsView.mCursorItems; - c.moveToPosition(position); // Remember old values before delete (for share below) String itemName = c.getString(mStringItemsITEMNAME); long oldstatus = c.getLong(mStringItemsSTATUS); @@ -2438,7 +2460,9 @@ public void onAction(String name) { new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { - deleteItem(mDeleteItemPosition); + deleteItem(mDeleteItemId); + mDeleteItemId = null; + mDeleteItemName = null; } } ) @@ -2498,18 +2522,9 @@ protected void onPrepareDialog(int id, Dialog dialog) { DownloadOIAppDialog.onPrepareDialog(this, dialog); break; case DIALOG_DELETE_ITEM: - if (mItemsView != null && dialog instanceof AlertDialog) { - ListAdapter adapter = mItemsView.getAdapter(); - if (adapter != null && adapter instanceof CursorAdapter) { - Cursor c = (Cursor) adapter.getItem(mDeleteItemPosition); - if (c != null) { - String itemName = c.getString(mStringItemsITEMNAME); - if (itemName != null) { - ((AlertDialog) dialog).setMessage( - getResources().getString(R.string.delete_item_confirm, itemName)); - } - } - } + if (dialog instanceof AlertDialog && mDeleteItemName != null) { + ((AlertDialog) dialog).setMessage( + getResources().getString(R.string.delete_item_confirm, mDeleteItemName)); } break; default: @@ -2936,6 +2951,7 @@ boolean isCurrentListShared() { */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); if (debug) { Log.i(TAG, "ShoppingView: onActivityResult. "); } @@ -2991,15 +3007,13 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { Log.d(TAG, "result received"); } - if (RESULT_OK == resultCode) { - int position = mMoveItemPosition; - if (mMoveItemPosition >= 0) { - moveItem(position, Integer.parseInt(data.getData() - .getLastPathSegment())); - } + if (RESULT_OK == resultCode && mMoveContainsId != null + && data != null && data.getData() != null) { + moveItem(mMoveContainsId, Integer.parseInt(data.getData() + .getLastPathSegment())); } - mMoveItemPosition = -1; + mMoveContainsId = null; } } @@ -3043,7 +3057,9 @@ private void setSpinnerAndDrawerListAdapter(ListAdapter adapter) { @Override public void onItemChanged() { - mItemsView.mCursorItems.requery(); + if (mItemsView.mCursorItems != null) { + mItemsView.mCursorItems.requery(); + } fillAutoCompleteTextViewAdapter(mEditText); } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingListsActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingListsActivity.kt index 084d7016..a20a7fe4 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingListsActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/ShoppingListsActivity.kt @@ -121,7 +121,9 @@ open class ShoppingListsActivity : ListActivity() { arrayOf(ShoppingContract.Lists.NAME), null, null, null ) if (c != null && c.moveToFirst()) { - return c.getString(0) + val title = c.getString(0) + c.close() + return title ?: getString(R.string.app_name) } c?.close() diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ComposeShoppingActivity.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ComposeShoppingActivity.kt index f4a027a6..1427460b 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ComposeShoppingActivity.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ComposeShoppingActivity.kt @@ -1,23 +1,143 @@ package org.openintents.shopping.ui.compose +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.hardware.Sensor +import android.hardware.SensorManager +import android.view.WindowManager +import android.graphics.Color +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import org.openintents.intents.ShoppingListIntents +import org.openintents.shopping.data.ListMode +import org.openintents.shopping.data.NewItem +import org.openintents.util.ShakeSensorListener +import org.openintents.shopping.library.provider.ShoppingContract +import org.openintents.shopping.widgets.CheckItemsWidget import org.openintents.shopping.ui.compose.theme.OiShoppingTheme /** - * Host for the new Compose UI. Kept separate from the legacy ShoppingActivity so - * the app keeps working while screens are migrated to Compose one at a time. + * Host for the Compose UI. Registered in the manifest as + * org.openintents.shopping.ShoppingActivity (the launcher entry). + * + * Opens the list given as intent data (content://org.openintents.shopping/lists/N), + * as sent by list shortcuts, the widget and other apps. */ -class ComposeShoppingActivity : ComponentActivity() { +open class ComposeShoppingActivity : ComponentActivity() { + + private val viewModel: ShoppingListViewModel by viewModels { ShoppingListViewModel.Factory } + override fun onCreate(savedInstanceState: Bundle?) { + // Draw behind the system bars and resize with the keyboard on every API level; + // the Scaffold applies the insets. + // The app is always dark: light system-bar icons regardless of the system theme. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) super.onCreate(savedInstanceState) + if (savedInstanceState == null) showListFrom(intent) setContent { OiShoppingTheme { - val vm: ShoppingListViewModel = viewModel(factory = ShoppingListViewModel.Factory) - ShoppingListRoute(vm) + ShoppingListRoute(viewModel) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + showListFrom(intent) + } + + private val shakeListener = object : ShakeSensorListener() { + override fun onShake() { + // "Shake to clean up" (only while shopping, like the legacy UI). + if (viewModel.state.value.mode == ListMode.SHOPPING) viewModel.cleanup() + } + } + + override fun onResume() { + super.onResume() + // Settings that act on the window (they can change in Settings). + @Suppress("DEPRECATION") + val prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(this) + requestedOrientation = prefs.getString("orientation", "-1")?.toIntOrNull() + ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (prefs.getBoolean("screenlock", false)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + if (prefs.getBoolean("shake", false)) { + val sensors = getSystemService(SENSOR_SERVICE) as SensorManager + sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { + sensors.registerListener(shakeListener, it, SensorManager.SENSOR_DELAY_UI) } } } + + override fun onPause() { + (getSystemService(SENSOR_SERVICE) as SensorManager).unregisterListener(shakeListener) + super.onPause() + } + + override fun onStop() { + super.onStop() + updateWidgets() + } + + private fun showListFrom(intent: Intent?) { + val listId = listIdFrom(intent?.data) + val items = itemsFrom(intent) + if (items.isNotEmpty()) { + // Shared text / INSERT_FROM_EXTRAS (forwarded by ShoppingListsActivity). + viewModel.addItemsFromIntent(listId, items) + // Don't add them again if the same intent is delivered once more. + intent?.removeExtra(ShoppingListIntents.EXTRA_STRING_ARRAYLIST_SHOPPING) + } else { + listId?.let(viewModel::showList) + } + } + + /** Home-screen widgets show list items; refresh them when leaving the app. */ + private fun updateWidgets() { + val context = applicationContext + Thread { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds(ComponentName(context, CheckItemsWidget::class.java)) + if (ids.isNotEmpty()) CheckItemsWidget().onUpdate(context, manager, ids) + }.start() + } + + companion object { + /** Items sent as string array lists in the extras (see ShoppingListIntents). */ + fun itemsFrom(intent: Intent?): List { + val extras = intent?.extras ?: return emptyList() + val names = extras.getStringArrayList(ShoppingListIntents.EXTRA_STRING_ARRAYLIST_SHOPPING) + ?: return emptyList() + val quantities = extras.getStringArrayList(ShoppingListIntents.EXTRA_STRING_ARRAYLIST_QUANTITY) + val prices = extras.getStringArrayList(ShoppingListIntents.EXTRA_STRING_ARRAYLIST_PRICE) + val barcodes = extras.getStringArrayList(ShoppingListIntents.EXTRA_STRING_ARRAYLIST_BARCODE) + return names.mapIndexedNotNull { i, name -> + if (name.isNullOrBlank()) null + else NewItem(name, quantities?.getOrNull(i), prices?.getOrNull(i), barcodes?.getOrNull(i)) + } + } + + /** The list id of a content://org.openintents.shopping/lists/N URI, else null. */ + fun listIdFrom(uri: Uri?): Long? { + if (uri == null || uri.authority != ShoppingContract.AUTHORITY) return null + val segments = uri.pathSegments + if (segments.size != 2 || segments[0] != "lists") return null + return segments[1].toLongOrNull() + } + } } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListScreen.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListScreen.kt index 52dfe7e3..8713b694 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListScreen.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListScreen.kt @@ -4,6 +4,21 @@ import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import kotlin.math.roundToInt +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column @@ -12,10 +27,17 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -24,11 +46,14 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -51,6 +76,8 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable @@ -60,26 +87,44 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import org.openintents.shopping.R import org.openintents.shopping.data.ItemEdit +import org.openintents.shopping.data.ListFilters import org.openintents.shopping.data.ListMode import org.openintents.shopping.data.ListTheme import org.openintents.shopping.data.ListTotals import org.openintents.shopping.data.ShoppingItem import org.openintents.shopping.data.ShoppingListInfo -import org.openintents.shopping.data.SortMode import org.openintents.shopping.data.StoreInfo +import org.openintents.shopping.data.lineCents +import org.openintents.shopping.library.provider.ShoppingContract.Status import org.openintents.shopping.library.util.PriceConverter import org.openintents.shopping.ui.compose.settings.SettingsActivity @@ -90,6 +135,9 @@ import org.openintents.shopping.ui.compose.settings.SettingsActivity @Composable fun ShoppingListRoute(viewModel: ShoppingListViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() + // Data can change while we are in the background (widget, legacy UI, + // automation, Settings), so reload whenever the screen is shown again. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() } ShoppingListScreen( state = state, onSetMode = viewModel::setMode, @@ -101,12 +149,13 @@ fun ShoppingListRoute(viewModel: ShoppingListViewModel) { onRestoreStatus = viewModel::restoreStatus, onUpdateItem = viewModel::updateItem, onRemoveItem = viewModel::removeItem, - onSetSortMode = viewModel::setSortMode, + onSetSortOrder = viewModel::setSortOrder, onToggleHideChecked = viewModel::toggleHideChecked, onCleanup = viewModel::cleanup, onAddStore = viewModel::addStore, onRemoveStore = viewModel::removeStore, onLoadItemEditData = viewModel::loadItemEditData, + onEndItemEdit = viewModel::endItemEdit, onSetStorePrice = viewModel::setStorePrice, onSelectStore = viewModel::selectStore, onExport = viewModel::exportTo, @@ -117,6 +166,19 @@ fun ShoppingListRoute(viewModel: ShoppingListViewModel) { onDeleteList = viewModel::deleteCurrentList, onSetTheme = viewModel::setTheme, onConsumeScroll = viewModel::consumeScrollTarget, + onSetStoreFilter = viewModel::setStoreFilter, + onSetTagFilter = viewModel::setTagFilter, + onUndoBulkChange = viewModel::undoBulkChange, + onConsumeBulkChange = viewModel::consumeBulkChange, + onMoveItem = viewModel::moveItem, + onCopyItem = viewModel::copyItem, + onDeleteItem = viewModel::deleteItem, + onConsumeEditRequest = viewModel::consumeEditRequest, + onSetThemeForAllLists = viewModel::setThemeForAllLists, + onScannedBarcode = viewModel::addScannedBarcode, + onNameUnknownBarcode = viewModel::nameUnknownBarcode, + onDismissUnknownBarcode = viewModel::dismissUnknownBarcode, + onConsumeAddedFromBarcode = viewModel::consumeAddedFromBarcode, ) } @@ -133,12 +195,13 @@ fun ShoppingListScreen( onRestoreStatus: (containsId: Long, status: Long) -> Unit, onUpdateItem: (ShoppingItem, ItemEdit) -> Unit, onRemoveItem: (ShoppingItem) -> Unit, - onSetSortMode: (SortMode) -> Unit, + onSetSortOrder: (Int) -> Unit, onToggleHideChecked: () -> Unit, onCleanup: () -> Unit, onAddStore: (String) -> Unit, onRemoveStore: (StoreInfo) -> Unit, onLoadItemEditData: (Long) -> Unit, + onEndItemEdit: () -> Unit, onSetStorePrice: (itemId: Long, storeId: Long, priceCents: Long?) -> Unit, onSelectStore: (Long?) -> Unit, onExport: (android.net.Uri) -> Unit, @@ -149,20 +212,59 @@ fun ShoppingListScreen( onDeleteList: () -> Unit, onSetTheme: (ListTheme) -> Unit, onConsumeScroll: () -> Unit, + onSetStoreFilter: (Long?) -> Unit = {}, + onSetTagFilter: (String?) -> Unit = {}, + onUndoBulkChange: () -> Unit = {}, + onConsumeBulkChange: () -> Unit = {}, + onMoveItem: (ShoppingItem, Long) -> Unit = { _, _ -> }, + onCopyItem: (ShoppingItem) -> Unit = {}, + onDeleteItem: (ShoppingItem) -> Unit = {}, + onConsumeEditRequest: () -> Unit = {}, + onSetThemeForAllLists: (ListTheme) -> Unit = {}, + onScannedBarcode: (String) -> Unit = {}, + onNameUnknownBarcode: (String) -> Unit = {}, + onDismissUnknownBarcode: () -> Unit = {}, + onConsumeAddedFromBarcode: () -> Unit = {}, ) { val drawerState = rememberDrawerState(DrawerValue.Closed) val listState = rememberLazyListState() val scope = rememberCoroutineScope() val context = LocalContext.current - var showNewListDialog by remember { mutableStateOf(false) } - var showStoresDialog by remember { mutableStateOf(false) } - var showRenameDialog by remember { mutableStateOf(false) } - var showDeleteConfirm by remember { mutableStateOf(false) } - var showThemeDialog by remember { mutableStateOf(false) } - var editingItem by remember { mutableStateOf(null) } + // rememberSaveable: open dialogs survive rotation / activity recreation. + var showNewListDialog by rememberSaveable { mutableStateOf(false) } + var showStoresDialog by rememberSaveable { mutableStateOf(false) } + var showRenameDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } + var showThemeDialog by rememberSaveable { mutableStateOf(false) } + var showSortDialog by rememberSaveable { mutableStateOf(false) } + // The item being edited, by relation-row id (saveable, unlike the item itself). + var editingContainsId by rememberSaveable { mutableStateOf(null) } + val editingItem = editingContainsId?.let { id -> state.items.firstOrNull { it.containsId == id } } val snackbarHostState = remember { SnackbarHostState() } + // Text of the add field. With the field in the top bar it also searches the list. + var addText by rememberSaveable { mutableStateOf("") } + val searchQuery = if (state.addBarOnTop) addText.trim() else "" + val pickItemsSorted = remember(state.pickItems, searchQuery) { + state.pickItems.filter { it.matches(searchQuery) } // sorted by the repository + } + val shownItems = remember(state.visibleItems, searchQuery) { + state.visibleItems.filter { it.matches(searchQuery) } + } + val submitAdd = { + if (addText.isNotBlank()) { + onAddItem(addText) + addText = "" + } + } val theme = state.theme + val rowDetails = RowDetails( + state.showQuantity, state.showUnits, state.showTags, state.showPriority, state.compact + ) + val sendTitle = stringResource(R.string.send) + val markedFormat = stringResource(R.string.undoable_marked_item) + val unmarkedFormat = stringResource(R.string.undoable_unmarked_item) + val undoLabel = stringResource(R.string.undo) val fontFamily: FontFamily? = remember(theme) { theme.fontAsset?.let { FontFamily(Font(it, context.assets)) } } @@ -174,9 +276,66 @@ fun ShoppingListScreen( ActivityResultContracts.OpenDocument() ) { uri -> uri?.let(onImport) } + // Barcodes are scanned by a scanner app (ZXing intent, e.g. Binary Eye). + var showNoScanner by rememberSaveable { mutableStateOf(false) } + val scanLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + result.data?.getStringExtra("SCAN_RESULT")?.takeIf { it.isNotBlank() }?.let(onScannedBarcode) + } + val scanBarcode = { + try { + scanLauncher.launch( + Intent("com.google.zxing.client.android.SCAN").putExtra("SCAN_MODE", "PRODUCT_MODE") + ) + } catch (e: android.content.ActivityNotFoundException) { + showNoScanner = true + } + } + val addedFormat = stringResource(R.string.undoable_added_item) + LaunchedEffect(state.addedFromBarcode) { + val name = state.addedFromBarcode ?: return@LaunchedEffect + android.widget.Toast.makeText(context, String.format(addedFormat, name), android.widget.Toast.LENGTH_SHORT).show() + onConsumeAddedFromBarcode() + } + + // Undo for mark all / unmark all / clean up. + val resources = context.resources + LaunchedEffect(state.bulkChange) { + val change = state.bulkChange ?: return@LaunchedEffect + onConsumeBulkChange() + val count = when (change) { + BulkChange.CLEANED_UP -> R.plurals.undoable_clean_list + BulkChange.MARKED_ALL -> R.plurals.undoable_mark_all + BulkChange.UNMARKED_ALL -> R.plurals.undoable_unmark_all + } + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + message = resources.getQuantityString(count, state.bulkChangeCount, state.bulkChangeCount), + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) onUndoBulkChange() + } + + // A copied item opens in the editor. + LaunchedEffect(state.editRequest, state.items) { + val request = state.editRequest ?: return@LaunchedEffect + if (state.items.any { it.containsId == request }) { + editingContainsId = request + onConsumeEditRequest() + } + } + LaunchedEffect(state.userMessage) { state.userMessage?.let { - android.widget.Toast.makeText(context, it, android.widget.Toast.LENGTH_SHORT).show() + val text = when (it) { + UserMessage.EXPORTED -> R.string.export_finished + UserMessage.EXPORT_FAILED -> R.string.error_writing_file + UserMessage.IMPORTED -> R.string.import_finished + UserMessage.IMPORT_FAILED -> R.string.error_reading_file + } + android.widget.Toast.makeText(context, text, android.widget.Toast.LENGTH_SHORT).show() onConsumeMessage() } } @@ -184,7 +343,8 @@ fun ShoppingListScreen( // After an add, scroll the list to where the new item landed (sort decides position). LaunchedEffect(state.scrollToContainsId) { val target = state.scrollToContainsId ?: return@LaunchedEffect - val idx = state.visibleItems.indexOfFirst { it.containsId == target } + val shown = if (state.mode == ListMode.PICK_ITEMS) pickItemsSorted else shownItems + val idx = shown.indexOfFirst { it.containsId == target } if (idx >= 0) listState.animateScrollToItem(idx) onConsumeScroll() } @@ -210,13 +370,26 @@ fun ShoppingListScreen( topBar = { TopAppBar( title = { - Column { + if (state.addBarOnTop) { + TopBarAddField( + text = addText, + onTextChange = { addText = it }, + placeholder = state.currentListName.ifEmpty { stringResource(R.string.app_name) }, + capitalization = state.capitalization, + onSubmit = submitAdd, + onScan = scanBarcode.takeIf { state.showScanButton }, + scanBusy = state.lookingUpBarcode, + ) + } else Column { Text( - state.currentListName.ifEmpty { "Shopping list" }, + state.currentListName.ifEmpty { stringResource(R.string.app_name) }, style = MaterialTheme.typography.titleLarge, ) Text( - if (state.mode == ListMode.PICK_ITEMS) "Pick items" else "Shopping", + stringResource( + if (state.mode == ListMode.PICK_ITEMS) R.string.menu_pick_items + else R.string.menu_start_shopping + ), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -224,20 +397,21 @@ fun ShoppingListScreen( }, navigationIcon = { IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Filled.Menu, contentDescription = "Open lists") + Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.compose_open_lists)) } }, actions = { ListOptionsMenu( - sortMode = state.sortMode, hideChecked = state.hideChecked, - onSetSortMode = onSetSortMode, + onSort = { showSortDialog = true }, onToggleHideChecked = onToggleHideChecked, onCleanup = onCleanup, onMarkAll = onMarkAll, onRenameList = { showRenameDialog = true }, onDeleteList = { showDeleteConfirm = true }, - onSendList = { shareList(context, state.currentListName, state.items) }, + onSendList = { + shareList(context, state.currentListName, state.items, sendTitle) + }, onTheme = { showThemeDialog = true }, onManageStores = { showStoresDialog = true }, onImportCsv = { @@ -246,6 +420,22 @@ fun ShoppingListScreen( ) }, onExportCsv = { exportLauncher.launch("shoppinglist.csv") }, + onConvertCsv = { + context.startActivity( + Intent(context, org.openintents.convertcsv.shoppinglist.ConvertCsvActivity::class.java) + .setData( + android.net.Uri.withAppendedPath( + org.openintents.shopping.library.provider.ShoppingContract.Lists.CONTENT_URI, + state.currentListId.toString() + ) + ) + ) + }, + onAbout = { + context.startActivity( + Intent(context, org.openintents.distribution.about.About::class.java) + ) + }, ) } ) @@ -255,9 +445,21 @@ fun ShoppingListScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .background(Color(theme.backgroundArgb)) + // The Scaffold padding already covers the navigation bar; don't add it twice. + .consumeWindowInsets(padding) .imePadding() // lift the bottom add-bar above the soft keyboard ) { + if (state.filters.isActive || (state.useFilters && (state.stores.isNotEmpty() || state.tags.isNotEmpty()))) { + ListFilterRow( + filters = state.filters, + stores = state.stores, + tags = state.tags, + useFilters = state.useFilters, + onSetStoreFilter = onSetStoreFilter, + onSetTagFilter = onSetTagFilter, + ) + HorizontalDivider() + } if (state.stores.isNotEmpty()) { StoreFilterRow( stores = state.stores, @@ -266,38 +468,63 @@ fun ShoppingListScreen( ) HorizontalDivider() } + val shownEmpty = if (state.mode == ListMode.PICK_ITEMS) pickItemsSorted.isEmpty() + else shownItems.isEmpty() + // Only the list itself wears the list theme (like the legacy UI); + // app bar, totals and add bar keep the app's colors. + ThemedListArea(theme = theme, modifier = Modifier.weight(1f).fillMaxWidth()) { + if (shownEmpty && !state.loading) { + Text( + text = stringResource(R.string.no_items_available), + color = Color(theme.checkedTextArgb), + fontFamily = fontFamily, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(32.dp), + ) + } + // Compact view: rows without the 48dp touch-target padding of the checkbox. + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides + if (state.compact) Dp.Unspecified else LocalMinimumInteractiveComponentSize.current + ) { LazyColumn( state = listState, - modifier = Modifier.weight(1f).fillMaxWidth() + modifier = Modifier.fillMaxSize() ) { if (state.mode == ListMode.PICK_ITEMS) { - items( - state.pickItems.sortedBy { it.name.lowercase() }, - key = { it.containsId } - ) { item -> + items(pickItemsSorted, key = { it.containsId }) { item -> PickItemRow( item = item, theme = theme, fontFamily = fontFamily, + fontSize = state.fontSize, + compact = state.compact, onToggle = { onPickToggle(item) }, ) - HorizontalDivider() + if (theme.showDivider) HorizontalDivider() } return@LazyColumn } - items(state.visibleItems, key = { it.containsId }) { item -> + items(shownItems, key = { it.containsId }) { item -> ShoppingItemRow( item = item, theme = theme, fontFamily = fontFamily, + fontSize = state.fontSize, + showPrice = state.showPrice, + details = rowDetails, onToggle = { val originalStatus = item.status val wasBought = item.isBought onToggleItem(item) scope.launch { + // Only the latest action is undoable; don't queue stale snackbars. + snackbarHostState.currentSnackbarData?.dismiss() val result = snackbarHostState.showSnackbar( - message = if (wasBought) "Unmarked ${item.name}" else "Marked ${item.name}", - actionLabel = "Undo", + message = String.format( + if (wasBought) unmarkedFormat else markedFormat, item.name + ), + actionLabel = undoLabel, duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { @@ -305,16 +532,48 @@ fun ShoppingListScreen( } } }, - onClick = { editingItem = item }, + onEdit = { editingContainsId = item.containsId }, ) - HorizontalDivider() + if (theme.showDivider) HorizontalDivider() } } + } + if (state.fastScroll) { + FastScrollbar( + listState = listState, + itemCount = if (state.mode == ListMode.PICK_ITEMS) pickItemsSorted.size else shownItems.size, + color = Color(theme.checkedTextArgb), + modifier = Modifier.align(Alignment.TopEnd), + ) + } + } if (state.totals.hasAny) { HorizontalDivider() - TotalsBar(totals = state.totals) + TotalsBar( + totals = state.totals, + prioritySubtotal = state.prioritySubtotal, + priorityThreshold = state.prioritySubtotalThreshold, + ) + } + if (state.addBarOnTop) { + // Suggestions for the add/search field in the top bar. + SuggestionRow( + query = addText, + suggestions = state.suggestions, + onPick = { onAddItem(it); addText = "" }, + ) + } else { + AddItemRow( + text = addText, + onTextChange = { addText = it }, + suggestions = state.suggestions, + capitalization = state.capitalization, + onAdd = { onAddItem(it); addText = "" }, + onSubmit = submitAdd, + onScan = scanBarcode.takeIf { state.showScanButton }, + scanBusy = state.lookingUpBarcode, + ) } - AddItemRow(suggestions = state.suggestions, onAdd = onAddItem) } } } @@ -332,10 +591,10 @@ fun ShoppingListScreen( if (showRenameDialog) { TextEntryDialog( - title = "Rename list", - label = "List name", + title = stringResource(R.string.rename_list), + label = stringResource(R.string.compose_list_name), initial = state.currentListName, - confirmLabel = "Rename", + confirmLabel = stringResource(R.string.compose_rename), onDismiss = { showRenameDialog = false }, onConfirm = { onRenameList(it); showRenameDialog = false }, ) @@ -344,20 +603,62 @@ fun ShoppingListScreen( if (showDeleteConfirm) { AlertDialog( onDismissRequest = { showDeleteConfirm = false }, - title = { Text("Delete list") }, - text = { Text("Delete \"${state.currentListName}\" and its items?") }, + title = { Text(state.currentListName.ifEmpty { stringResource(R.string.delete_list) }) }, + text = { Text(stringResource(R.string.confirm_delete_list)) }, confirmButton = { - TextButton(onClick = { onDeleteList(); showDeleteConfirm = false }) { Text("Delete") } + TextButton(onClick = { onDeleteList(); showDeleteConfirm = false }) { + Text(stringResource(R.string.delete)) + } }, dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) } } ) } + if (showNoScanner) { + AlertDialog( + onDismissRequest = { showNoScanner = false }, + text = { Text(stringResource(R.string.compose_no_scanner)) }, + confirmButton = { + TextButton(onClick = { + showNoScanner = false + openStore(context, "de.markusfisch.android.binaryeye") + }) { Text(stringResource(R.string.compose_install)) } + }, + dismissButton = { + TextButton(onClick = { showNoScanner = false }) { Text(stringResource(R.string.cancel)) } + }, + ) + } + + state.unknownBarcode?.let { code -> + TextEntryDialog( + title = stringResource(R.string.compose_unknown_product, code), + message = if (state.unknownBarcodeOffline) stringResource(R.string.compose_lookup_offline) else null, + label = stringResource(R.string.item), + confirmLabel = stringResource(R.string.add), + onDismiss = onDismissUnknownBarcode, + onConfirm = onNameUnknownBarcode, + ) + } + + if (showSortDialog) { + ChoiceDialog( + title = stringResource(R.string.menu_sort_list), + labels = stringArrayResource(R.array.preference_sortorder_entries).toList(), + values = stringArrayResource(R.array.preference_sortorder_entryvalues).toList(), + selected = state.sortOrder.toString(), + onDismiss = { showSortDialog = false }, + onSelect = { onSetSortOrder(it.toInt()); showSortDialog = false }, + ) + } + if (showThemeDialog) { ThemeDialog( current = state.theme, + fontSize = state.fontSize, + onApplyToAllLists = { onSetThemeForAllLists(it); showThemeDialog = false }, onDismiss = { showThemeDialog = false }, onSelect = { onSetTheme(it); showThemeDialog = false }, ) @@ -372,23 +673,49 @@ fun ShoppingListScreen( ) } + // The item vanished (e.g. removed elsewhere): close its editor. + if (editingContainsId != null && editingItem == null && !state.loading) { + LaunchedEffect(editingContainsId) { + editingContainsId = null + onEndItemEdit() + } + } editingItem?.let { item -> LaunchedEffect(item.itemId) { onLoadItemEditData(item.itemId) } + val close = { + editingContainsId = null + onEndItemEdit() + } EditItemDialog( item = item, stores = state.stores, storePrices = state.editingStorePrices, note = state.editingNote, + loaded = state.editingLoaded, onSetStorePrice = { storeId, cents -> onSetStorePrice(item.itemId, storeId, cents) }, - onDismiss = { editingItem = null }, + onDismiss = close, onSave = { edit -> onUpdateItem(item, edit) - editingItem = null + close() }, onDelete = { onRemoveItem(item) - editingItem = null + close() + }, + otherLists = state.lists.filter { it.id != state.currentListId }, + onMove = { listId -> onMoveItem(item, listId); close() }, + onCopy = { onCopyItem(item); close() }, + onItemStores = { + // Which stores carry the item, aisle and price per store. + context.startActivity( + Intent(context, org.openintents.shopping.ui.ItemStoresActivity::class.java).setData( + org.openintents.shopping.library.provider.ShoppingContract.Lists.CONTENT_URI.buildUpon() + .appendPath(state.currentListId.toString()) + .appendPath(item.itemId.toString()).build() + ) + ) }, + onDeletePermanently = { onDeleteItem(item); close() }, ) } } @@ -404,20 +731,20 @@ private fun ListDrawerContent( ) { ModalDrawerSheet { NavigationDrawerItem( - label = { Text("Shopping") }, + label = { Text(stringResource(R.string.menu_start_shopping)) }, selected = mode == ListMode.SHOPPING, onClick = { onSetMode(ListMode.SHOPPING) }, modifier = Modifier.padding(horizontal = 12.dp), ) NavigationDrawerItem( - label = { Text("Pick items") }, + label = { Text(stringResource(R.string.menu_pick_items)) }, selected = mode == ListMode.PICK_ITEMS, onClick = { onSetMode(ListMode.PICK_ITEMS) }, modifier = Modifier.padding(horizontal = 12.dp), ) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text( - text = "Lists", + text = stringResource(R.string.compose_lists), modifier = Modifier.padding(16.dp), ) lists.forEach { list -> @@ -430,7 +757,7 @@ private fun ListDrawerContent( } Spacer(Modifier.height(8.dp)) NavigationDrawerItem( - label = { Text("New list…") }, + label = { Text(stringResource(R.string.new_list)) }, selected = false, icon = { Icon(Icons.Filled.Add, contentDescription = null) }, onClick = onNewList, @@ -456,7 +783,7 @@ private fun StoreFilterRow( FilterChip( selected = selectedStoreId == null, onClick = { onSelectStore(null) }, - label = { Text("All") }, + label = { Text(stringResource(R.string.compose_all_stores)) }, ) stores.forEach { store -> Spacer(Modifier.width(8.dp)) @@ -469,11 +796,70 @@ private fun StoreFilterRow( } } +/** + * The store / tag filters of the list (the "use_filters" setting). Active + * filters are always shown, so items are never hidden without a visible reason. + */ +@Composable +private fun ListFilterRow( + filters: ListFilters, + stores: List, + tags: List, + useFilters: Boolean, + onSetStoreFilter: (Long?) -> Unit, + onSetTagFilter: (String?) -> Unit, +) { + var storeMenu by remember { mutableStateOf(false) } + var tagMenu by remember { mutableStateOf(false) } + val unfiltered = stringResource(R.string.unfiltered) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (useFilters || filters.storeId != null) { + Box { + FilterChip( + selected = filters.storeId != null, + onClick = { storeMenu = true }, + label = { + Text(stores.firstOrNull { it.id == filters.storeId }?.name ?: stringResource(R.string.stores)) + }, + ) + DropdownMenu(expanded = storeMenu, onDismissRequest = { storeMenu = false }) { + DropdownMenuItem(text = { Text(unfiltered) }, onClick = { storeMenu = false; onSetStoreFilter(null) }) + stores.forEach { store -> + DropdownMenuItem( + text = { Text(store.name) }, + onClick = { storeMenu = false; onSetStoreFilter(store.id) }, + ) + } + } + } + Spacer(Modifier.width(8.dp)) + } + Box { + FilterChip( + selected = filters.tag != null, + onClick = { tagMenu = true }, + label = { Text(filters.tag ?: stringResource(R.string.tags)) }, + ) + DropdownMenu(expanded = tagMenu, onDismissRequest = { tagMenu = false }) { + DropdownMenuItem(text = { Text(unfiltered) }, onClick = { tagMenu = false; onSetTagFilter(null) }) + tags.forEach { tag -> + DropdownMenuItem(text = { Text(tag) }, onClick = { tagMenu = false; onSetTagFilter(tag) }) + } + } + } + } +} + @Composable private fun ListOptionsMenu( - sortMode: SortMode, hideChecked: Boolean, - onSetSortMode: (SortMode) -> Unit, + onSort: () -> Unit, onToggleHideChecked: () -> Unit, onCleanup: () -> Unit, onMarkAll: (Boolean) -> Unit, @@ -484,78 +870,82 @@ private fun ListOptionsMenu( onManageStores: () -> Unit, onImportCsv: () -> Unit, onExportCsv: () -> Unit, + onConvertCsv: () -> Unit = {}, + onAbout: () -> Unit = {}, ) { var expanded by remember { mutableStateOf(false) } IconButton(onClick = { expanded = true }) { - Icon(Icons.Filled.MoreVert, contentDescription = "More options") + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.compose_more_options)) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( - text = { Text("Sort: unchecked first") }, - leadingIcon = { if (sortMode == SortMode.UNCHECKED_FIRST) Icon(Icons.Filled.Check, null) }, - onClick = { onSetSortMode(SortMode.UNCHECKED_FIRST); expanded = false }, + text = { Text(stringResource(R.string.menu_sort_list)) }, + onClick = { onSort(); expanded = false }, ) DropdownMenuItem( - text = { Text("Sort: alphabetical") }, - leadingIcon = { if (sortMode == SortMode.ALPHABETICAL) Icon(Icons.Filled.Check, null) }, - onClick = { onSetSortMode(SortMode.ALPHABETICAL); expanded = false }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text(if (hideChecked) "Show checked items" else "Hide checked items") }, + text = { Text(stringResource(if (hideChecked) R.string.compose_show_checked_items else R.string.preference_hidechecked_title)) }, onClick = { onToggleHideChecked(); expanded = false }, ) DropdownMenuItem( - text = { Text("Clean up (remove checked)") }, + text = { Text(stringResource(R.string.clean_up_list)) }, onClick = { onCleanup(); expanded = false }, ) DropdownMenuItem( - text = { Text("Mark all items") }, + text = { Text(stringResource(R.string.mark_all_items)) }, onClick = { onMarkAll(true); expanded = false }, ) DropdownMenuItem( - text = { Text("Unmark all items") }, + text = { Text(stringResource(R.string.unmark_all_items)) }, onClick = { onMarkAll(false); expanded = false }, ) HorizontalDivider() DropdownMenuItem( - text = { Text("Rename list") }, + text = { Text(stringResource(R.string.rename_list)) }, onClick = { onRenameList(); expanded = false }, ) DropdownMenuItem( - text = { Text("Delete list") }, + text = { Text(stringResource(R.string.delete_list)) }, onClick = { onDeleteList(); expanded = false }, ) DropdownMenuItem( - text = { Text("Send list") }, + text = { Text(stringResource(R.string.send)) }, onClick = { onSendList(); expanded = false }, ) DropdownMenuItem( - text = { Text("Theme") }, + text = { Text(stringResource(R.string.theme)) }, onClick = { onTheme(); expanded = false }, ) HorizontalDivider() DropdownMenuItem( - text = { Text("Stores…") }, + text = { Text(stringResource(R.string.menu_item_stores)) }, onClick = { onManageStores(); expanded = false }, ) HorizontalDivider() DropdownMenuItem( - text = { Text("Import CSV…") }, + text = { Text(stringResource(R.string.compose_import_csv)) }, onClick = { onImportCsv(); expanded = false }, ) DropdownMenuItem( - text = { Text("Export CSV…") }, + text = { Text(stringResource(R.string.compose_export_csv)) }, onClick = { onExportCsv(); expanded = false }, ) + // The full CSV screen: HandyShopper format, encoding, import policy. + DropdownMenuItem( + text = { Text(stringResource(R.string.convert_csv)) }, + onClick = { onConvertCsv(); expanded = false }, + ) val context = LocalContext.current DropdownMenuItem( - text = { Text("Settings") }, + text = { Text(stringResource(R.string.preferences)) }, onClick = { context.startActivity(Intent(context, SettingsActivity::class.java)) expanded = false }, ) + DropdownMenuItem( + text = { Text(stringResource(org.openintents.distribution.R.string.oi_distribution_about)) }, + onClick = { onAbout(); expanded = false }, + ) } } @@ -569,11 +959,11 @@ private fun ManageStoresDialog( var newStore by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Stores") }, + title = { Text(stringResource(R.string.stores)) }, text = { Column { if (stores.isEmpty()) { - Text("No stores yet.") + Text(stringResource(R.string.no_stores_available)) } stores.forEach { store -> Row( @@ -582,7 +972,7 @@ private fun ManageStoresDialog( ) { Text(store.name, modifier = Modifier.weight(1f)) IconButton(onClick = { onRemoveStore(store) }) { - Icon(Icons.Filled.Delete, contentDescription = "Remove ${store.name}") + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.compose_remove_store, store.name)) } } } @@ -591,7 +981,7 @@ private fun ManageStoresDialog( OutlinedTextField( value = newStore, onValueChange = { newStore = it }, - label = { Text("Add store") }, + label = { Text(stringResource(R.string.compose_add_store)) }, singleLine = true, modifier = Modifier.weight(1f), ) @@ -603,52 +993,203 @@ private fun ManageStoresDialog( } } ) { - Icon(Icons.Filled.Add, contentDescription = "Add store") + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.compose_add_store)) } } } }, confirmButton = { - TextButton(onClick = onDismiss) { Text("Done") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.compose_done)) } } ) } +/** + * The list area in the list theme's colors; the Classic theme draws the notepad + * paper (a 9-patch, whose padding keeps the text inside the paper's margins). + */ +@Composable +private fun ThemedListArea( + theme: ListTheme, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val context = LocalContext.current + val paper = remember(theme) { + if (theme.paperBackground) { + androidx.core.content.ContextCompat.getDrawable(context, R.drawable.shoppinglist01d) + } else null + } + val paperPadding = remember(paper) { + android.graphics.Rect().also { paper?.getPadding(it) } + } + val density = LocalDensity.current + Box( + modifier = modifier + .background(Color(theme.backgroundArgb)) + .drawBehind { + paper?.let { d -> + d.setBounds(0, 0, size.width.toInt(), size.height.toInt()) + drawIntoCanvas { d.draw(it.nativeCanvas) } + } + } + .padding( + with(density) { + androidx.compose.foundation.layout.PaddingValues( + start = paperPadding.left.toDp(), top = paperPadding.top.toDp(), + end = paperPadding.right.toDp(), bottom = paperPadding.bottom.toDp(), + ) + } + ) + ) { + content() + } +} + +/** + * Fast scroll ("fastscroll" setting): a thumb at the right edge that shows the + * position in the list; dragging or tapping the track jumps through the list. + */ +@Composable +private fun FastScrollbar( + listState: LazyListState, + itemCount: Int, + color: Color, + modifier: Modifier = Modifier, +) { + val visibleCount = listState.layoutInfo.visibleItemsInfo.size + if (itemCount == 0 || visibleCount >= itemCount) return + val scope = rememberCoroutineScope() + val density = LocalDensity.current + BoxWithConstraints(modifier = modifier.fillMaxHeight().width(28.dp)) { + val trackPx = constraints.maxHeight.toFloat() + val thumbPx = maxOf(trackPx * visibleCount / itemCount, with(density) { 40.dp.toPx() }) + val scrollable = (itemCount - visibleCount).coerceAtLeast(1) + val fraction = (listState.firstVisibleItemIndex.toFloat() / scrollable).coerceIn(0f, 1f) + val jumpTo = { y: Float -> + val target = ((y / trackPx) * itemCount).toInt().coerceIn(0, itemCount - 1) + scope.launch { listState.scrollToItem(target) } + } + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(itemCount, trackPx) { + detectVerticalDragGestures( + onDragStart = { jumpTo(it.y) }, + onVerticalDrag = { change, _ -> jumpTo(change.position.y) }, + ) + } + .pointerInput(itemCount, trackPx) { detectTapGestures { jumpTo(it.y) } } + ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset { IntOffset(0, ((trackPx - thumbPx) * fraction).roundToInt()) } + .padding(end = 4.dp) + .width(6.dp) + .height(with(density) { thumbPx.toDp() }) + .background(color, RoundedCornerShape(3.dp)) + ) + } +} + +/** An item's name as the theme shows it (upper-case fonts, "... OK" suffix). */ +@Composable +private fun themedName(theme: ListTheme, text: String, checked: Boolean): String { + val base = if (theme.upperCase) text.uppercase() else text + return if (checked && theme.checkedSuffix) base + stringResource(R.string.suffix_checked) else base +} + +/** Which item details a row shows (the "show..." settings). */ +private data class RowDetails( + val quantity: Boolean = true, + val units: Boolean = true, + val tags: Boolean = true, + val priority: Boolean = true, + /** Compact view: less padding, so more items fit on the screen. */ + val compact: Boolean = false, +) + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ShoppingItemRow( item: ShoppingItem, theme: ListTheme, fontFamily: FontFamily?, + fontSize: Int, + showPrice: Boolean, onToggle: () -> Unit, - onClick: () -> Unit, + onEdit: () -> Unit, + details: RowDetails = RowDetails(), ) { val struck = item.isBought && theme.strikethroughChecked val decoration = if (struck) TextDecoration.LineThrough else TextDecoration.None val color = Color(if (item.isBought) theme.checkedTextArgb else theme.textArgb) + val textSize = theme.textSizeSp(fontSize).sp + val editLabel = stringResource(R.string.menu_edit_item) + // Like the legacy UI: tap marks the item, long-press edits it. Themes without + // a checkbox show the state through color / strike-through / suffix only. Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), + .combinedClickable( + onClick = onToggle, + onLongClick = onEdit, + onLongClickLabel = editLabel, + ) + .padding(horizontal = 8.dp, vertical = if (details.compact) 0.dp else 4.dp) + .heightIn(min = if (details.compact) 28.dp else 40.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked = item.isBought, onCheckedChange = { onToggle() }) + if (theme.showCheckBox) { + Checkbox( + checked = item.isBought, + onCheckedChange = { onToggle() }, + modifier = Modifier.semantics { contentDescription = item.name }, + ) + } val label = buildString { - if (!item.quantity.isNullOrBlank()) append(item.quantity).append(" ") + if (details.quantity && !item.quantity.isNullOrBlank()) append(item.quantity).append(' ') + if (details.units && !item.units.isNullOrBlank()) append(item.units).append(' ') append(item.name) } - Text( - text = label, - color = color, - fontFamily = fontFamily, - textDecoration = decoration, - modifier = Modifier.weight(1f).padding(start = 8.dp) - ) - item.priceCents?.let { cents -> + Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( - text = PriceConverter.getStringFromCentPrice(cents), + text = themedName(theme, label, item.isBought), color = color, fontFamily = fontFamily, + fontSize = textSize, + textDecoration = decoration, + ) + if (details.tags && !item.tags.isNullOrBlank()) { + Text( + text = if (theme.upperCase) item.tags.uppercase() else item.tags, + color = Color(theme.priceArgb), + fontFamily = fontFamily, + fontSize = textSize * 0.6f, + ) + } + } + if (details.priority && !item.priority.isNullOrBlank()) { + // A small badge, so it is not mistaken for part of the price. + Text( + text = item.priority, + color = Color(theme.priorityArgb), + fontFamily = fontFamily, + fontSize = textSize * 0.6f, + modifier = Modifier + .padding(start = 8.dp) + .border(1.dp, Color(theme.priorityArgb), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp) + ) + } + // Line cost (price * quantity), like the legacy UI and the totals. + lineCents(item)?.takeIf { showPrice }?.let { cents -> + Text( + text = PriceConverter.getStringFromCentPrice(cents), + color = Color(if (item.isBought) theme.checkedTextArgb else theme.priceArgb), + fontFamily = fontFamily, + fontSize = textSize * 0.7f, textDecoration = decoration, modifier = Modifier.padding(start = 8.dp) ) @@ -661,7 +1202,9 @@ private fun PickItemRow( item: ShoppingItem, theme: ListTheme, fontFamily: FontFamily?, + fontSize: Int, onToggle: () -> Unit, + compact: Boolean = false, ) { // In pick mode the checkbox means "on this list"; off-list items are dimmed. val color = Color(if (item.isOnList) theme.textArgb else theme.checkedTextArgb) @@ -669,38 +1212,113 @@ private fun PickItemRow( modifier = Modifier .fillMaxWidth() .clickable(onClick = onToggle) - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = if (compact) 0.dp else 4.dp) + .heightIn(min = if (compact) 28.dp else 40.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked = item.isOnList, onCheckedChange = { onToggle() }) + if (theme.showCheckBox) { + Checkbox( + checked = item.isOnList, + onCheckedChange = { onToggle() }, + modifier = Modifier.semantics { contentDescription = item.name }, + ) + } Text( - text = item.name, + text = themedName(theme, item.name, checked = false), color = color, fontFamily = fontFamily, + fontSize = theme.textSizeSp(fontSize).sp, modifier = Modifier.weight(1f).padding(start = 8.dp) ) } } +/** A single-choice dialog (radio buttons), e.g. the sort order. */ @Composable -private fun ThemeDialog(current: ListTheme, onDismiss: () -> Unit, onSelect: (ListTheme) -> Unit) { +private fun ChoiceDialog( + title: String, + labels: List, + values: List, + selected: String, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Theme") }, + title = { Text(title) }, text = { - Column { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + labels.zip(values).forEach { (label, value) -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onSelect(value) }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = value == selected, onClick = { onSelect(value) }) + Text(label, modifier = Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } + ) +} + +private fun ListTheme.labelRes(): Int = when (this) { + ListTheme.DEFAULT -> R.string.theme_default + ListTheme.CLASSIC -> R.string.theme_classic + ListTheme.ANDROID -> R.string.theme_bugdroid +} + +/** Theme picker: each option is a small live preview of a list in that theme. */ +@Composable +private fun ThemeDialog( + current: ListTheme, + fontSize: Int, + onApplyToAllLists: (ListTheme) -> Unit, + onDismiss: () -> Unit, + onSelect: (ListTheme) -> Unit, +) { + val context = LocalContext.current + val sample = listOf( + ShoppingItem(-1, -1, stringResource(R.string.theme_preview_item_1), Status.WANT_TO_BUY, null, null, null, null), + ShoppingItem(-2, -2, stringResource(R.string.theme_preview_item_2), Status.BOUGHT, null, null, null, null), + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.theme)) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { ListTheme.entries.forEach { t -> + val font = remember(t) { t.fontAsset?.let { FontFamily(Font(it, context.assets)) } } Row( - modifier = Modifier.fillMaxWidth().clickable { onSelect(t) }.padding(vertical = 10.dp), + modifier = Modifier.fillMaxWidth().clickable { onSelect(t) }.padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton(selected = t == current, onClick = { onSelect(t) }) - Text(t.displayName, modifier = Modifier.padding(start = 8.dp)) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text(stringResource(t.labelRes())) + ThemedListArea(theme = t, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) { + Column { + sample.forEach { item -> + ShoppingItemRow( + item = item, theme = t, fontFamily = font, + fontSize = fontSize.coerceAtMost(1), showPrice = false, + onToggle = { onSelect(t) }, onEdit = { onSelect(t) }, + ) + } + } + } + } } } } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } } + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.compose_done)) } }, + dismissButton = { + TextButton(onClick = { onApplyToAllLists(current) }) { + Text(stringResource(R.string.use_theme_for_all_lists)) + } + }, ) } @@ -710,21 +1328,30 @@ private fun EditItemDialog( stores: List, storePrices: Map, note: String?, + /** Note and store prices are loaded; saving earlier would erase them. */ + loaded: Boolean, onSetStorePrice: (storeId: Long, priceCents: Long?) -> Unit, onDismiss: () -> Unit, onSave: (ItemEdit) -> Unit, onDelete: () -> Unit, + otherLists: List = emptyList(), + onMove: (Long) -> Unit = {}, + onCopy: () -> Unit = {}, + onDeletePermanently: () -> Unit = {}, + onItemStores: () -> Unit = {}, ) { - var name by remember { mutableStateOf(item.name) } - var quantity by remember { mutableStateOf(item.quantity.orEmpty()) } - var price by remember { + var showMove by rememberSaveable { mutableStateOf(false) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + var name by rememberSaveable { mutableStateOf(item.name) } + var quantity by rememberSaveable { mutableStateOf(item.quantity.orEmpty()) } + var price by rememberSaveable { mutableStateOf(item.priceCents?.let { PriceConverter.getStringFromCentPrice(it) } ?: "") } - var units by remember { mutableStateOf(item.units.orEmpty()) } - var priority by remember { mutableStateOf(item.priority.orEmpty()) } - var tags by remember { mutableStateOf(item.tags.orEmpty()) } + var units by rememberSaveable { mutableStateOf(item.units.orEmpty()) } + var priority by rememberSaveable { mutableStateOf(item.priority.orEmpty()) } + var tags by rememberSaveable { mutableStateOf(item.tags.orEmpty()) } // Note loads asynchronously after the dialog opens; seed when it arrives. - var noteText by remember(note) { mutableStateOf(note.orEmpty()) } + var noteText by rememberSaveable(note) { mutableStateOf(note.orEmpty()) } // Per-store price text, re-seeded when the loaded prices arrive. val storePriceText = remember(stores, storePrices) { mutableStateMapOf().apply { @@ -736,59 +1363,62 @@ private fun EditItemDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Edit item") }, + title = { Text(stringResource(R.string.menu_edit_item)) }, text = { - Column { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Name") }, + label = { Text(stringResource(R.string.item)) }, singleLine = true, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = quantity, onValueChange = { quantity = it }, - label = { Text("Quantity") }, + label = { Text(stringResource(R.string.quantity)) }, singleLine = true, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = units, onValueChange = { units = it }, - label = { Text("Units") }, + label = { Text(stringResource(R.string.units)) }, singleLine = true, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = price, onValueChange = { price = it }, - label = { Text("Price") }, + label = { Text(stringResource(R.string.price)) }, singleLine = true, + isError = !isValidPrice(price), + supportingText = if (isValidPrice(price)) null + else ({ Text(stringResource(R.string.compose_invalid_price)) }), ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = priority, onValueChange = { priority = it }, - label = { Text("Priority") }, + label = { Text(stringResource(R.string.priority)) }, singleLine = true, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = tags, onValueChange = { tags = it }, - label = { Text("Tags") }, + label = { Text(stringResource(R.string.tags)) }, singleLine = true, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = noteText, onValueChange = { noteText = it }, - label = { Text("Note") }, + label = { Text(stringResource(R.string.note)) }, ) if (stores.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - Text("Per-store prices") + Text(stringResource(R.string.compose_per_store_prices)) stores.forEach { store -> Spacer(Modifier.height(4.dp)) OutlinedTextField( @@ -796,17 +1426,30 @@ private fun EditItemDialog( onValueChange = { storePriceText[store.id] = it }, label = { Text(store.name) }, singleLine = true, + isError = !isValidPrice(storePriceText[store.id].orEmpty()), ) } } Spacer(Modifier.height(8.dp)) - TextButton(onClick = onDelete) { Text("Remove from list") } + TextButton(onClick = onDelete) { Text(stringResource(R.string.menu_remove_item)) } + if (otherLists.isNotEmpty()) { + TextButton(onClick = { showMove = true }) { Text(stringResource(R.string.menu_move_item)) } + } + TextButton(onClick = onCopy) { Text(stringResource(R.string.menu_copy_item)) } + if (stores.isNotEmpty()) { + TextButton(onClick = onItemStores) { Text(stringResource(R.string.menu_item_stores)) } + } + TextButton(onClick = { confirmDelete = true }) { Text(stringResource(R.string.menu_delete_item)) } } }, confirmButton = { TextButton( + enabled = loaded, onClick = { - if (name.isNotBlank()) { + // Never save a typo'd price: that would silently erase the stored one. + val pricesValid = isValidPrice(price) && + stores.all { isValidPrice(storePriceText[it.id].orEmpty()) } + if (name.isNotBlank() && pricesValid) { val cents = if (price.isBlank()) null else PriceConverter.getCentPriceFromString(price) onSave( ItemEdit( @@ -827,23 +1470,80 @@ private fun EditItemDialog( } } } - ) { Text("Save") } + ) { Text(stringResource(R.string.compose_save)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } ) + + if (showMove) { + AlertDialog( + onDismissRequest = { showMove = false }, + title = { Text(stringResource(R.string.menu_move_item)) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + otherLists.forEach { list -> + Text( + list.name, + modifier = Modifier + .fillMaxWidth() + .clickable { showMove = false; onMove(list.id) } + .padding(vertical = 12.dp) + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showMove = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(stringResource(R.string.menu_delete_item)) }, + text = { Text(stringResource(R.string.delete_item_confirm, item.name)) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDeletePermanently() }) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } } +private fun isValidPrice(text: String): Boolean = + text.isBlank() || PriceConverter.getCentPriceFromString(text) != null + @Composable -private fun TotalsBar(totals: ListTotals) { +private fun TotalsBar(totals: ListTotals, prioritySubtotal: Long, priorityThreshold: Int) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Text(text = "To buy: ${formatTotal(totals.toBuyCents)}", modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.total, formatTotal(totals.toBuyCents)), + modifier = Modifier.weight(1f) + ) + if (prioritySubtotal != 0L && priorityThreshold in 1..4) { + val caption = when (priorityThreshold) { + 1 -> R.string.priority1_total + 2 -> R.string.priority2_total + 3 -> R.string.priority3_total + else -> R.string.priority4_total + } + Text( + text = stringResource(caption, formatTotal(prioritySubtotal)), + modifier = Modifier.padding(end = 12.dp) + ) + } if (totals.boughtCents > 0) { - Text(text = "Bought: ${formatTotal(totals.boughtCents)}") + Text(text = stringResource(R.string.total_checked, formatTotal(totals.boughtCents))) } } } @@ -852,18 +1552,21 @@ private fun TotalsBar(totals: ListTotals) { private fun formatTotal(cents: Long): String = if (cents == 0L) "0.00" else PriceConverter.getStringFromCentPrice(cents) +/** Case-insensitive search of the list (empty query matches everything). */ +private fun ShoppingItem.matches(query: String): Boolean = + query.isEmpty() || name.contains(query, ignoreCase = true) + +private fun keyboardCapitalization(capitalization: Int) = when (capitalization) { + 0 -> KeyboardCapitalization.None + 2 -> KeyboardCapitalization.Words + else -> KeyboardCapitalization.Sentences +} + +/** Catalogue names matching what's typed (case-insensitive); prefix matches first. */ @Composable -private fun AddItemRow(suggestions: List, onAdd: (String) -> Unit) { - var newItem by remember { mutableStateOf("") } - val submit = { - if (newItem.isNotBlank()) { - onAdd(newItem) - newItem = "" - } - } - // Catalogue names matching what's typed (case-insensitive); prefix matches first. - val matches = remember(newItem, suggestions) { - val q = newItem.trim() +private fun SuggestionRow(query: String, suggestions: List, onPick: (String) -> Unit) { + val matches = remember(query, suggestions) { + val q = query.trim() if (q.isBlank()) emptyList() else suggestions.asSequence() .filter { it.contains(q, ignoreCase = true) && !it.equals(q, ignoreCase = true) } @@ -871,74 +1574,183 @@ private fun AddItemRow(suggestions: List, onAdd: (String) -> Unit) { .take(8) .toList() } - Column(modifier = Modifier.fillMaxWidth()) { - if (matches.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - matches.forEach { name -> - // Tapping a suggestion adds it straight away (fast re-add). - SuggestionChip( - onClick = { onAdd(name); newItem = "" }, - label = { Text(name) }, - ) - Spacer(Modifier.width(8.dp)) - } - } + if (matches.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + matches.forEach { name -> + // Tapping a suggestion adds it straight away (fast re-add). + SuggestionChip(onClick = { onPick(name) }, label = { Text(name) }) + Spacer(Modifier.width(8.dp)) } + } +} + +/** The add field at the bottom of the screen. */ +@Composable +private fun AddItemRow( + text: String, + onTextChange: (String) -> Unit, + suggestions: List, + capitalization: Int, + onAdd: (String) -> Unit, + onSubmit: () -> Unit, + /** Null hides the scan button ("barcode_button" setting). */ + onScan: (() -> Unit)?, + /** A scanned barcode is being looked up. */ + scanBusy: Boolean, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SuggestionRow(query = text, suggestions = suggestions, onPick = onAdd) Row( modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( - value = newItem, - onValueChange = { newItem = it }, - label = { Text("Add item") }, + value = text, + onValueChange = onTextChange, + label = { Text(stringResource(R.string.new_item)) }, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { submit() }), + keyboardOptions = KeyboardOptions( + capitalization = keyboardCapitalization(capitalization), + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), modifier = Modifier.weight(1f) ) - IconButton(onClick = submit) { - Icon(Icons.Filled.Add, contentDescription = "Add") + if (text.isEmpty() && onScan != null) ScanButton(onScan, busy = scanBusy) + IconButton(onClick = onSubmit) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add)) } } } } +/** The barcode scan button; a spinner while a scanned barcode is looked up. */ +@Composable +private fun ScanButton(onScan: () -> Unit, busy: Boolean) { + if (busy) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } else { + IconButton(onClick = onScan) { + Icon( + painterResource(R.drawable.ic_barcode), + contentDescription = stringResource(R.string.compose_scan_barcode), + ) + } + } +} + +/** + * The search/add field in the top bar ("holosearch" setting): typing filters + * the list, Enter or + adds the text as a new item. + */ +@Composable +private fun TopBarAddField( + text: String, + onTextChange: (String) -> Unit, + placeholder: String, + capitalization: Int, + onSubmit: () -> Unit, + /** Null hides the scan button ("barcode_button" setting). */ + onScan: (() -> Unit)?, + /** A scanned barcode is being looked up. */ + scanBusy: Boolean, +) { + TextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text(placeholder, maxLines = 1) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + trailingIcon = { + if (text.isEmpty()) { + if (onScan != null) ScanButton(onScan, busy = scanBusy) + } else { + Row { + IconButton(onClick = { onTextChange("") }) { + Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.compose_clear)) + } + IconButton(onClick = onSubmit) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add)) + } + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = keyboardCapitalization(capitalization), + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ), + modifier = Modifier.fillMaxWidth().semantics { + contentDescription = placeholder + }, + ) +} + @Composable private fun TextEntryDialog( title: String, + /** Optional text above the field. */ + message: String? = null, label: String, initial: String = "", confirmLabel: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { - var text by remember { mutableStateOf(initial) } + var text by rememberSaveable { mutableStateOf(initial) } AlertDialog( onDismissRequest = onDismiss, title = { Text(title) }, text = { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - label = { Text(label) }, - singleLine = true, - ) + Column { + if (message != null) { + Text(message, modifier = Modifier.padding(bottom = 8.dp)) + } + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(label) }, + singleLine = true, + ) + } }, confirmButton = { TextButton(onClick = { if (text.isNotBlank()) onConfirm(text) }) { Text(confirmLabel) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } ) } -private fun shareList(context: android.content.Context, listName: String, items: List) { +/** Opens an app's page in the installed app store (F-Droid, Play...), else on F-Droid's website. */ +private fun openStore(context: android.content.Context, packageName: String) { + val market = Intent(Intent.ACTION_VIEW, android.net.Uri.parse("market://details?id=$packageName")) + try { + context.startActivity(market) + } catch (e: android.content.ActivityNotFoundException) { + context.startActivity( + Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://f-droid.org/packages/$packageName/")) + ) + } +} + +private fun shareList( + context: android.content.Context, + listName: String, + items: List, + chooserTitle: String, +) { val body = buildString { append(listName).append('\n') items.forEach { item -> @@ -952,30 +1764,30 @@ private fun shareList(context: android.content.Context, listName: String, items: putExtra(Intent.EXTRA_SUBJECT, listName) putExtra(Intent.EXTRA_TEXT, body) } - context.startActivity(Intent.createChooser(intent, "Send list")) + context.startActivity(Intent.createChooser(intent, chooserTitle)) } @Composable private fun NewListDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { - var name by remember { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("New list") }, + title = { Text(stringResource(R.string.new_list)) }, text = { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("List name") }, + label = { Text(stringResource(R.string.compose_list_name)) }, singleLine = true, ) }, confirmButton = { TextButton( onClick = { if (name.isNotBlank()) onConfirm(name) }, - ) { Text("Create") } + ) { Text(stringResource(R.string.compose_create)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } ) } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListViewModel.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListViewModel.kt index 83fa9546..f37b902e 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListViewModel.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/ShoppingListViewModel.kt @@ -11,21 +11,37 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openintents.shopping.data.ItemEdit import org.openintents.shopping.data.ListMode import org.openintents.shopping.data.ListTheme +import org.openintents.shopping.data.ItemSnapshot +import org.openintents.shopping.data.ListFilters import org.openintents.shopping.data.ListTotals +import org.openintents.shopping.BuildConfig +import org.openintents.shopping.data.NewItem +import org.openintents.shopping.data.OpenFoodFactsLookup +import org.openintents.shopping.data.LookupResult +import org.openintents.shopping.data.ProductLookup import org.openintents.shopping.data.ProviderShoppingRepository import org.openintents.shopping.data.ShoppingItem import org.openintents.shopping.data.ShoppingListInfo +import org.openintents.shopping.data.SettingsRepository +import org.openintents.shopping.data.SharedPrefsSettingsRepository import org.openintents.shopping.data.ShoppingRepository -import org.openintents.shopping.data.SortMode import org.openintents.shopping.data.StoreInfo import org.openintents.shopping.data.arrangeItems import org.openintents.shopping.data.computeTotals +import org.openintents.shopping.data.prioritySubtotal + +/** A bulk change that can be undone. */ +enum class BulkChange { MARKED_ALL, UNMARKED_ALL, CLEANED_UP } + +/** One-shot feedback shown to the user (the UI maps it to a translated string). */ +enum class UserMessage { EXPORTED, EXPORT_FAILED, IMPORTED, IMPORT_FAILED } /** Immutable UI state for the shopping screen. */ data class ShoppingUiState( @@ -39,13 +55,61 @@ data class ShoppingUiState( val storePricesForList: Map = emptyMap(), val editingStorePrices: Map = emptyMap(), val editingNote: String? = null, - val sortMode: SortMode = SortMode.UNCHECKED_FIRST, + /** The item whose note/store prices are loaded into [editingNote]/[editingStorePrices]. */ + val editingItemId: Long? = null, + /** True once the note / store prices of [editingItemId] are loaded (Save waits for it). */ + val editingLoaded: Boolean = false, + /** The list's sort order (a legacy sort order value, see ShoppingRepository.getSortOrder). */ + val sortOrder: Int = 0, val hideChecked: Boolean = false, val theme: ListTheme = ListTheme.DEFAULT, + /** "showprice" setting. */ + val showPrice: Boolean = true, + /** "capitalization" setting: 0 = none, 1 = sentences, 2 = words. */ + val capitalization: Int = 1, + /** "fontsize" setting: 0 tiny, 1 small, 2 medium, 3 large (see ListTheme.textSizeSp). */ + val fontSize: Int = 2, + /** "holosearch" setting: the search/add field is in the top bar instead of at the bottom. */ + val addBarOnTop: Boolean = false, + /** Which item details the rows show ("showquantity", "showunits", "showtags", "showpriority"). */ + val showQuantity: Boolean = true, + val showUnits: Boolean = true, + val showTags: Boolean = true, + val showPriority: Boolean = true, + /** "priority_subtotal_threshold" (0 = off) and "priosubtotal_includes_checked". */ + val prioritySubtotalThreshold: Int = 0, + val prioritySubtotalIncludesChecked: Boolean = true, + /** A scanned barcode no product name was found for: the UI asks for a name. */ + val unknownBarcode: String? = null, + /** The online lookup of [unknownBarcode] failed for lack of a connection. */ + val unknownBarcodeOffline: Boolean = false, + /** A scanned barcode is being looked up (the UI shows progress). */ + val lookingUpBarcode: Boolean = false, + /** Name of an item just added from a barcode (the UI confirms it, then consumes it). */ + val addedFromBarcode: String? = null, + /** "barcode_button" setting: show the scan button next to the add field. */ + val showScanButton: Boolean = true, + /** "barcode_lookup" setting: look up scanned barcodes on Open Food Facts. */ + val barcodeLookup: Boolean = true, + /** "compact" setting: denser rows, more items on the screen. */ + val compact: Boolean = false, + /** "fastscroll" setting: a draggable scroll thumb for long lists. */ + val fastScroll: Boolean = false, + /** "use_filters" setting: offer the store / tag filter. */ + val useFilters: Boolean = false, + /** The list's filters and the tags that can be filtered by. */ + val filters: ListFilters = ListFilters(), + val tags: List = emptyList(), + /** Set after mark all / unmark all / clean up: the UI offers an undo, then consumes it. */ + val bulkChange: BulkChange? = null, + /** How many items [bulkChange] changed. */ + val bulkChangeCount: Int = 0, + /** Set after a copy: the UI opens this row in the editor, then consumes it. */ + val editRequest: Long? = null, /** Catalogue item names for the add-field auto-suggestions. */ val suggestions: List = emptyList(), val loading: Boolean = true, - val userMessage: String? = null, + val userMessage: UserMessage? = null, /** Set after an add so the list can scroll to the new item; the UI consumes it. */ val scrollToContainsId: Long? = null, ) { @@ -62,11 +126,15 @@ data class ShoppingUiState( /** The items to render, after the user's sort + filter (derived). */ val visibleItems: List - get() = arrangeItems(effectiveItems, sortMode, hideChecked) + get() = arrangeItems(effectiveItems, hideChecked) /** Money totals (using the selected store's prices when a store is selected). */ val totals: ListTotals get() = computeTotals(effectiveItems) + + /** The priority subtotal (0 when off). */ + val prioritySubtotal: Long + get() = prioritySubtotal(effectiveItems, prioritySubtotalThreshold, prioritySubtotalIncludesChecked) } /** @@ -79,9 +147,17 @@ class ShoppingListViewModel( private val repository: ShoppingRepository, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val contentResolver: android.content.ContentResolver? = null, + private val settings: SettingsRepository? = null, + private val productLookup: ProductLookup? = null, ) : ViewModel() { private val _state = MutableStateFlow(ShoppingUiState()) + + /** State before the last mark all / unmark all / clean up, for its undo. */ + private var lastBulkChange: List = emptyList() + + /** A list requested (e.g. by a shortcut) before the initial load finished. */ + private var pendingListId: Long? = null val state: StateFlow = _state.asStateFlow() init { @@ -92,30 +168,122 @@ class ShoppingListViewModel( val id = repository.getDefaultListId() id to repository.getLists() } - _state.update { it.copy(lists = lists, currentListId = defaultId) } + // Read on the main thread, where showList() writes it. + val requested = pendingListId?.takeIf { p -> lists.any { it.id == p } } + pendingListId = null + if (requested != null) rememberActiveList(requested) + _state.update { it.copy(lists = lists, currentListId = requested ?: defaultId) } + loadSettings() + refresh() + } + } + + /** Re-reads the settings the Compose UI honors (they can change in Settings). */ + private suspend fun loadSettings() { + val s = settings ?: return + val loaded = withContext(ioDispatcher) { + _state.value.copy( + hideChecked = s.getBoolean(PREF_HIDE_CHECKED, false), + showPrice = s.getBoolean(PREF_SHOW_PRICE, true), + capitalization = s.getString(PREF_CAPITALIZATION, "1").toIntOrNull() + ?.takeIf { it in 0..2 } ?: 1, + fontSize = s.getString(PREF_FONT_SIZE, "2").toIntOrNull()?.takeIf { it in 0..3 } ?: 2, + addBarOnTop = s.getBoolean(PREF_ADD_BAR_ON_TOP, false), + showQuantity = s.getBoolean("showquantity", true), + showUnits = s.getBoolean("showunits", true), + showTags = s.getBoolean("showtags", true), + showPriority = s.getBoolean("showpriority", true), + prioritySubtotalThreshold = s.getString("priority_subtotal_threshold", "0") + .toIntOrNull()?.takeIf { it in 0..4 } ?: 0, + prioritySubtotalIncludesChecked = s.getBoolean("priosubtotal_includes_checked", true), + useFilters = s.getBoolean("use_filters", false), + compact = s.getBoolean("compact", false), + barcodeLookup = s.getBoolean(PREF_BARCODE_LOOKUP, true), + showScanButton = s.getBoolean("barcode_button", true), + fastScroll = s.getBoolean("fastscroll", false), + ) + } + _state.update { + it.copy( + hideChecked = loaded.hideChecked, showPrice = loaded.showPrice, + capitalization = loaded.capitalization, fontSize = loaded.fontSize, + addBarOnTop = loaded.addBarOnTop, + showQuantity = loaded.showQuantity, showUnits = loaded.showUnits, + showTags = loaded.showTags, showPriority = loaded.showPriority, + prioritySubtotalThreshold = loaded.prioritySubtotalThreshold, + prioritySubtotalIncludesChecked = loaded.prioritySubtotalIncludesChecked, + useFilters = loaded.useFilters, + compact = loaded.compact, fastScroll = loaded.fastScroll, + barcodeLookup = loaded.barcodeLookup, showScanButton = loaded.showScanButton, + ) + } + } + + /** + * Reloads everything when the screen comes back to the foreground: the data + * may have been changed by the widget, the legacy UI, automation or Settings. + */ + fun onResume() { + if (_state.value.currentListId < 0) return // initial load still running + viewModelScope.launch { + loadSettings() + val lists = withContext(ioDispatcher) { repository.getLists() } + val current = _state.value.currentListId + if (lists.none { it.id == current }) { + // The current list was deleted elsewhere. + val newId = withContext(ioDispatcher) { repository.getDefaultListId() } + val newLists = withContext(ioDispatcher) { repository.getLists() } + _state.update { + it.copy( + lists = newLists, currentListId = newId, loading = true, + selectedStoreId = null, storePricesForList = emptyMap() + ) + } + } else { + _state.update { it.copy(lists = lists) } + } refresh() } } fun refresh() = viewModelScope.launch { val listId = _state.value.currentListId + if (listId < 0) return@launch + val mode = _state.value.mode val storeId = _state.value.selectedStoreId - val (items, stores) = withContext(ioDispatcher) { - repository.getItems(listId) to repository.getStores(listId) + data class Loaded( + val items: List, + val stores: List, + val storePrices: Map, + val theme: ListTheme, + val suggestions: List, + val pickItems: List, + val sortOrder: Int, + val filters: ListFilters, + val tags: List, + ) + val loaded = withContext(ioDispatcher) { + Loaded( + items = repository.getItems(listId), + stores = repository.getStores(listId), + storePrices = if (storeId != null) repository.getStorePricesForList(storeId) else emptyMap(), + theme = repository.getListTheme(listId), + suggestions = repository.getItemNameSuggestions(), + pickItems = if (mode == ListMode.PICK_ITEMS) repository.getAllListItems(listId) else emptyList(), + sortOrder = repository.getSortOrder(listId), + filters = repository.getListFilters(listId), + tags = repository.getListTags(listId), + ) } - val storePrices = if (storeId != null) { - withContext(ioDispatcher) { repository.getStorePricesForList(storeId) } - } else emptyMap() - val theme = withContext(ioDispatcher) { repository.getListTheme(listId) } - val suggestions = withContext(ioDispatcher) { repository.getItemNameSuggestions() } - val pickItems = if (_state.value.mode == ListMode.PICK_ITEMS) { - withContext(ioDispatcher) { repository.getAllListItems(listId) } - } else emptyList() _state.update { - it.copy( - items = items, pickItems = pickItems, stores = stores, - storePricesForList = storePrices, theme = theme, - suggestions = suggestions, loading = false + // Drop the result if the user switched list/mode/store while loading: + // a newer refresh() is on its way and must not be overwritten. + if (it.currentListId != listId || it.mode != mode || it.selectedStoreId != storeId) it + else it.copy( + items = loaded.items, pickItems = loaded.pickItems, stores = loaded.stores, + storePricesForList = loaded.storePrices, theme = loaded.theme, + suggestions = loaded.suggestions, sortOrder = loaded.sortOrder, + filters = loaded.filters, tags = loaded.tags, loading = false ) } } @@ -132,14 +300,52 @@ class ShoppingListViewModel( refresh() } + /** "Use this theme for all lists". */ + fun setThemeForAllLists(theme: ListTheme) = viewModelScope.launch { + withContext(ioDispatcher) { repository.getLists().forEach { repository.setListTheme(it.id, theme) } } + _state.update { it.copy(theme = theme) } + } + fun setTheme(theme: ListTheme) = viewModelScope.launch { val listId = _state.value.currentListId withContext(ioDispatcher) { repository.setListTheme(listId, theme) } _state.update { it.copy(theme = theme) } } + /** Shows [listId] (from an intent); ignored if there is no such list. */ + fun showList(listId: Long) { + if (_state.value.currentListId < 0) { + pendingListId = listId // initial load still running; it picks this up + return + } + viewModelScope.launch { + // Re-read: the list may have been created while we were in the background. + val lists = withContext(ioDispatcher) { repository.getLists() } + _state.update { it.copy(lists = lists) } + if (lists.any { it.id == listId }) selectList(listId) + } + } + + /** + * Adds items sent by another app (shared text, INSERT_FROM_EXTRAS) to + * [listId], or to the current list when null. + */ + fun addItemsFromIntent(listId: Long?, items: List) { + if (items.none { it.name.isNotBlank() }) return + viewModelScope.launch { + // Wait for the initial load, which decides the current list. + _state.first { it.currentListId >= 0 } + val lists = withContext(ioDispatcher) { repository.getLists() } + val target = listId?.takeIf { id -> lists.any { it.id == id } } ?: _state.value.currentListId + withContext(ioDispatcher) { repository.addItems(target, items) } + _state.update { it.copy(lists = lists) } + if (target != _state.value.currentListId) selectList(target) else refresh() + } + } + fun selectList(listId: Long) { if (listId == _state.value.currentListId) return + rememberActiveList(listId) _state.update { it.copy( currentListId = listId, loading = true, @@ -150,15 +356,10 @@ class ShoppingListViewModel( } fun selectStore(storeId: Long?) { - if (storeId == null) { - _state.update { it.copy(selectedStoreId = null, storePricesForList = emptyMap()) } - return - } - _state.update { it.copy(selectedStoreId = storeId) } - viewModelScope.launch { - val prices = withContext(ioDispatcher) { repository.getStorePricesForList(storeId) } - _state.update { it.copy(storePricesForList = prices) } - } + if (storeId == _state.value.selectedStoreId) return + _state.update { it.copy(selectedStoreId = storeId, storePricesForList = emptyMap()) } + // refresh() loads the selected store's prices together with the items. + if (storeId != null) refresh() } fun createList(name: String) = viewModelScope.launch { @@ -167,7 +368,11 @@ class ShoppingListViewModel( val id = repository.createList(name.trim()) id } - val lists = withContext(ioDispatcher) { repository.getLists() } + if (newId < 0) return@launch + val lists = withContext(ioDispatcher) { + repository.setActiveList(newId) + repository.getLists() + } _state.update { it.copy( lists = lists, currentListId = newId, loading = true, @@ -179,8 +384,10 @@ class ShoppingListViewModel( fun addItem(name: String) = viewModelScope.launch { val listId = _state.value.currentListId + if (listId < 0) return@launch val itemId = withContext(ioDispatcher) { repository.addItem(listId, name) } refresh().join() + if (itemId < 0 || _state.value.currentListId != listId) return@launch // Tell the UI to scroll to where the new item landed (sort decides the position). val containsId = _state.value.items.firstOrNull { it.itemId == itemId }?.containsId _state.update { it.copy(scrollToContainsId = containsId) } @@ -188,7 +395,55 @@ class ShoppingListViewModel( fun consumeScrollTarget() = _state.update { it.copy(scrollToContainsId = null) } + /** + * Adds the product with this barcode: an item that already has the barcode, + * else the name from Open Food Facts (if enabled); if neither is known the UI + * asks for a name ([ShoppingUiState.unknownBarcode]). + */ + fun addScannedBarcode(barcode: String) = viewModelScope.launch { + val code = barcode.trim() + if (code.isEmpty() || _state.value.currentListId < 0) return@launch + val lookup = productLookup?.takeIf { _state.value.barcodeLookup } + _state.update { it.copy(lookingUpBarcode = true) } + val result = try { + withContext(ioDispatcher) { + repository.getItemNameForBarcode(code)?.let { LookupResult.Found(it) } + ?: lookup?.lookup(code) + ?: LookupResult.NotFound + } + } finally { + _state.update { it.copy(lookingUpBarcode = false) } + } + if (result is LookupResult.Found) { + addWithBarcode(result.name, code) + } else { + _state.update { + it.copy(unknownBarcode = code, unknownBarcodeOffline = result == LookupResult.Offline) + } + } + } + + /** The user named a product whose barcode was unknown. */ + fun nameUnknownBarcode(name: String) { + val code = _state.value.unknownBarcode ?: return + _state.update { it.copy(unknownBarcode = null, unknownBarcodeOffline = false) } + if (name.isNotBlank()) viewModelScope.launch { addWithBarcode(name.trim(), code) } + } + + fun dismissUnknownBarcode() = _state.update { it.copy(unknownBarcode = null, unknownBarcodeOffline = false) } + + fun consumeAddedFromBarcode() = _state.update { it.copy(addedFromBarcode = null) } + + private suspend fun addWithBarcode(name: String, barcode: String) { + val listId = _state.value.currentListId + withContext(ioDispatcher) { repository.addItems(listId, listOf(NewItem(name, barcode = barcode))) } + refresh().join() + val added = _state.value.items.firstOrNull { it.name.equals(name, ignoreCase = true) } + _state.update { it.copy(addedFromBarcode = name, scrollToContainsId = added?.containsId) } + } + fun toggle(item: ShoppingItem) = viewModelScope.launch { + // The repository flips the stored status, so quick double taps work. withContext(ioDispatcher) { repository.toggleItemBought(item) } refresh() } @@ -210,19 +465,84 @@ class ShoppingListViewModel( refresh() } - fun setSortMode(mode: SortMode) = _state.update { it.copy(sortMode = mode) } + fun setSortOrder(sortOrder: Int) = viewModelScope.launch { + val listId = _state.value.currentListId + if (listId < 0) return@launch + withContext(ioDispatcher) { repository.setSortOrder(listId, sortOrder) } + refresh() + } - fun toggleHideChecked() = _state.update { it.copy(hideChecked = !it.hideChecked) } + fun toggleHideChecked() { + _state.update { it.copy(hideChecked = !it.hideChecked) } + val hide = _state.value.hideChecked + settings?.let { s -> viewModelScope.launch(ioDispatcher) { s.setBoolean(PREF_HIDE_CHECKED, hide) } } + } fun cleanup() = viewModelScope.launch { val listId = _state.value.currentListId - withContext(ioDispatcher) { repository.cleanupList(listId) } + val changed = withContext(ioDispatcher) { repository.cleanupList(listId) } + offerUndo(changed, BulkChange.CLEANED_UP) refresh() } fun markAll(bought: Boolean) = viewModelScope.launch { val listId = _state.value.currentListId - withContext(ioDispatcher) { repository.markAllItems(listId, bought) } + val changed = withContext(ioDispatcher) { repository.markAllItems(listId, bought) } + offerUndo(changed, if (bought) BulkChange.MARKED_ALL else BulkChange.UNMARKED_ALL) + refresh() + } + + private fun offerUndo(changed: List, change: BulkChange) { + if (changed.isEmpty()) return + lastBulkChange = changed + _state.update { it.copy(bulkChange = change, bulkChangeCount = changed.size) } + } + + /** The UI showed the undo snackbar for [ShoppingUiState.bulkChange]. */ + fun consumeBulkChange() = _state.update { it.copy(bulkChange = null) } + + /** Undoes the last mark all / unmark all / clean up. */ + fun undoBulkChange() = viewModelScope.launch { + val snapshots = lastBulkChange + lastBulkChange = emptyList() + if (snapshots.isEmpty()) return@launch + withContext(ioDispatcher) { repository.restore(snapshots) } + refresh() + } + + /** Only show items at [storeId] (null = all); needs the "use_filters" setting. */ + fun setStoreFilter(storeId: Long?) = viewModelScope.launch { + val listId = _state.value.currentListId + withContext(ioDispatcher) { repository.setStoreFilter(listId, storeId) } + refresh() + } + + /** Only show items with [tag] (null = all). */ + fun setTagFilter(tag: String?) = viewModelScope.launch { + val listId = _state.value.currentListId + withContext(ioDispatcher) { repository.setTagFilter(listId, tag) } + refresh() + } + + fun moveItem(item: ShoppingItem, targetListId: Long) = viewModelScope.launch { + if (targetListId == _state.value.currentListId) return@launch + withContext(ioDispatcher) { repository.moveItem(item, targetListId) } + refresh() + } + + /** Copies an item; the UI then opens the copy in the editor ([ShoppingUiState.editRequest]). */ + fun copyItem(item: ShoppingItem) = viewModelScope.launch { + val newContainsId = withContext(ioDispatcher) { repository.copyItem(item) } + refresh().join() + if (newContainsId != null) _state.update { it.copy(editRequest = newContainsId) } + } + + fun consumeEditRequest() = _state.update { it.copy(editRequest = null) } + + /** Deletes an item for good (and from the catalogue if no other list has it). */ + fun deleteItem(item: ShoppingItem) = viewModelScope.launch { + val listId = _state.value.currentListId + withContext(ioDispatcher) { repository.deleteItem(listId, item) } refresh() } @@ -242,6 +562,7 @@ class ShoppingListViewModel( repository.deleteList(listId) // Switch to another list, or recreate the default if none remain. val pickId = repository.getLists().firstOrNull()?.id ?: repository.getDefaultListId() + repository.setActiveList(pickId) pickId to repository.getLists() } _state.update { @@ -261,21 +582,43 @@ class ShoppingListViewModel( fun removeStore(store: StoreInfo) = viewModelScope.launch { withContext(ioDispatcher) { repository.removeStore(store.id) } + if (_state.value.selectedStoreId == store.id) { + _state.update { it.copy(selectedStoreId = null, storePricesForList = emptyMap()) } + } refresh() } /** Loads the per-store prices AND note for [itemId] (call when opening item edit). */ - fun loadItemEditData(itemId: Long) = viewModelScope.launch { - val (prices, note) = withContext(ioDispatcher) { - repository.getItemStorePrices(itemId) to repository.getItemNote(itemId) + fun loadItemEditData(itemId: Long) { + // Already loaded (e.g. the dialog was recomposed after a rotation): keep it. + if (_state.value.editingItemId == itemId) return + // Clear the previous item's values so they never show (or get saved) for this one. + _state.update { + it.copy( + editingItemId = itemId, editingLoaded = false, + editingStorePrices = emptyMap(), editingNote = null, + ) + } + viewModelScope.launch { + val (prices, note) = withContext(ioDispatcher) { + repository.getItemStorePrices(itemId) to repository.getItemNote(itemId) + } + _state.update { + if (it.editingItemId != itemId) it + else it.copy(editingStorePrices = prices, editingNote = note, editingLoaded = true) + } } - _state.update { it.copy(editingStorePrices = prices, editingNote = note) } + } + + /** The item editor was closed. */ + fun endItemEdit() = _state.update { + it.copy(editingItemId = null, editingLoaded = false, editingStorePrices = emptyMap(), editingNote = null) } fun setStorePrice(itemId: Long, storeId: Long, priceCents: Long?) = viewModelScope.launch { withContext(ioDispatcher) { repository.setItemStorePrice(itemId, storeId, priceCents) } val prices = withContext(ioDispatcher) { repository.getItemStorePrices(itemId) } - _state.update { it.copy(editingStorePrices = prices) } + _state.update { if (it.editingItemId != itemId) it else it.copy(editingStorePrices = prices) } } fun exportTo(uri: android.net.Uri) = viewModelScope.launch { @@ -287,7 +630,7 @@ class ShoppingListViewModel( } ?: throw java.io.IOException("Cannot open output stream") } } - _state.update { it.copy(userMessage = if (result.isSuccess) "Exported" else "Export failed") } + _state.update { it.copy(userMessage = if (result.isSuccess) UserMessage.EXPORTED else UserMessage.EXPORT_FAILED) } } fun importFrom(uri: android.net.Uri) = viewModelScope.launch { @@ -296,21 +639,41 @@ class ShoppingListViewModel( runCatching { cr.openInputStream(uri)?.use { ins -> java.io.InputStreamReader(ins).use { r -> + // KEEP (the legacy default): existing items keep their tags/prices. repository.importCsv( r, - org.openintents.convertcsv.common.ConvertCsvBaseActivity.IMPORT_POLICY_OVERWRITE + org.openintents.convertcsv.common.ConvertCsvBaseActivity.IMPORT_POLICY_KEEP ) } } ?: throw java.io.IOException("Cannot open input stream") } } + // An import can create lists: reload them for the drawer. + val lists = withContext(ioDispatcher) { repository.getLists() } + _state.update { + it.copy( + lists = lists, + userMessage = if (result.isSuccess) UserMessage.IMPORTED else UserMessage.IMPORT_FAILED, + ) + } refresh() - _state.update { it.copy(userMessage = if (result.isSuccess) "Imported" else "Import failed") } } fun consumeMessage() = _state.update { it.copy(userMessage = null) } + private fun rememberActiveList(listId: Long) { + viewModelScope.launch(ioDispatcher) { repository.setActiveList(listId) } + } + companion object { + private const val PREF_HIDE_CHECKED = "hidechecked" + private const val PREF_SHOW_PRICE = "showprice" + private const val PREF_CAPITALIZATION = "capitalization" + private const val PREF_FONT_SIZE = "fontsize" + private const val PREF_BARCODE_LOOKUP = "barcode_lookup" + /** Same key as the legacy "search/add items in action bar" layout choice. */ + private const val PREF_ADD_BAR_ON_TOP = "holosearch" + /** Factory that wires the provider-backed repository from the Application context. */ val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { @@ -318,6 +681,10 @@ class ShoppingListViewModel( ShoppingListViewModel( ProviderShoppingRepository(app), contentResolver = app.contentResolver, + settings = SharedPrefsSettingsRepository(app), + productLookup = OpenFoodFactsLookup( + "OI Shopping List/${BuildConfig.VERSION_NAME} (https://github.com/openintents/shoppinglist)" + ), ) } } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/AppSettingsCatalog.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/AppSettingsCatalog.kt index d90f7b92..7a9f36c1 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/AppSettingsCatalog.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/AppSettingsCatalog.kt @@ -2,56 +2,101 @@ package org.openintents.shopping.ui.compose.settings import org.openintents.shopping.R +/** A setting on the Settings screen. */ +sealed interface Setting { + val key: String + val titleRes: Int +} + /** A boolean (switch) setting. */ -data class BoolSetting(val key: String, val titleRes: Int, val default: Boolean) +data class BoolSetting(override val key: String, override val titleRes: Int, val default: Boolean) : Setting /** A single-choice (dropdown) setting, with entry labels + values from string arrays. */ data class ChoiceSetting( - val key: String, - val titleRes: Int, + override val key: String, + override val titleRes: Int, val default: String, val entriesRes: Int, val valuesRes: Int, -) +) : Setting + +/** A titled group of settings. */ +data class SettingsSection(val titleRes: Int, val settings: List) /** - * The app settings, mirroring res/xml/preferences.xml exactly (keys + defaults), - * so writing them is equivalent to the legacy PreferenceActivity. + * The settings the app honors, with the keys + defaults of res/xml/preferences.xml + * (shared with the legacy code, so earlier choices carry over). */ object AppSettingsCatalog { - val choices = listOf( - ChoiceSetting( - "fontsize", R.string.preference_fontsize, "2", - R.array.preference_fontsize_entries, R.array.preference_fontsize_entryvalues + val sections = listOf( + SettingsSection( + R.string.preference_general, listOf( + ChoiceSetting( + "fontsize", R.string.preference_fontsize, "2", + R.array.preference_fontsize_entries, R.array.preference_fontsize_entryvalues + ), + ChoiceSetting( + "capitalization", R.string.preference_capitalization_title, "1", + R.array.preference_capitalization_entries, R.array.preference_capitalization_entryvalues + ), + ChoiceSetting( + "orientation", R.string.preference_orientation, "-1", + R.array.preference_orientation_entries, R.array.preference_orientation_entryvalues + ), + BoolSetting("holosearch", R.string.preference_holo_search_title, false), + BoolSetting("compact", R.string.compose_compact_view, false), + BoolSetting("fastscroll", R.string.preference_fastscroll_title, false), + BoolSetting("hidechecked", R.string.preference_hidechecked_title, false), + BoolSetting("screenlock", R.string.preference_screenlock_title, false), + BoolSetting("shake", R.string.preference_shake_title, false), + BoolSetting("use_filters", R.string.preference_usefilters_title, false), + BoolSetting("resetquantity", R.string.preference_reset_quantity, false), + BoolSetting("barcode_button", R.string.compose_barcode_button, true), + BoolSetting("barcode_lookup", R.string.compose_barcode_lookup, true), + BoolSetting("autocomplete_only_this_list", R.string.preference_complete_by_list_title, false), + ) ), - ChoiceSetting( - "sortorder", R.string.preference_sortorder_title, "3", - R.array.preference_sortorder_entries, R.array.preference_sortorder_entryvalues + SettingsSection( + R.string.preference_appearance, listOf( + BoolSetting("showprice", R.string.preference_showprice_title, true), + BoolSetting("perstoreprices", R.string.preference_perstoreprice_title, false), + BoolSetting("showtags", R.string.preference_showtags_title, true), + BoolSetting("showunits", R.string.preference_showunits_title, true), + BoolSetting("showquantity", R.string.preference_showquantity_title, true), + BoolSetting("showpriority", R.string.preference_showpriority_title, true), + ) ), - ChoiceSetting( - "capitalization", R.string.preference_capitalization_title, "1", - R.array.preference_capitalization_entries, R.array.preference_capitalization_entryvalues + SettingsSection( + R.string.preference_sorting, listOf( + ChoiceSetting( + "sortorder", R.string.preference_sortorder_title, "3", + R.array.preference_sortorder_entries, R.array.preference_sortorder_entryvalues + ), + BoolSetting("perListSort", R.string.preference_perListSort_title, false), + BoolSetting("samesortforpick", R.string.preference_samesortforpick_title, false), + ChoiceSetting( + "sortorderForPickItems", R.string.preference_sortorder_for_pick_title, "1", + R.array.preference_sortorder_entries, R.array.preference_sortorder_entryvalues + ), + ChoiceSetting( + "sortorderForShoppingLists", R.string.preference_sortorder_for_shopping_lists, "0", + R.array.preference_sortorder_slists, R.array.preference_sortorder_slistsvalues + ), + ) ), - ChoiceSetting( - "orientation", R.string.preference_orientation, "-1", - R.array.preference_orientation_entries, R.array.preference_orientation_entryvalues + SettingsSection( + R.string.preference_prio_subtotal, listOf( + ChoiceSetting( + "priority_subtotal_threshold", R.string.preference_prioritytotal_title, "0", + R.array.preference_prioritytotal_entries, R.array.preference_prioritytotal_entryvalues + ), + BoolSetting("priosubtotal_includes_checked", R.string.preference_priosubtotalchecked_title, true), + ) ), ) - val toggles = listOf( - BoolSetting("hidechecked", R.string.preference_hidechecked_title, false), - BoolSetting("fastscroll", R.string.preference_fastscroll_title, false), - BoolSetting("loadlastused", R.string.preference_loadlastused_title, true), - BoolSetting("shake", R.string.preference_shake_title, false), - BoolSetting("perstoreprices", R.string.preference_perstoreprice_title, false), - BoolSetting("addforbarcode", R.string.preference_add_for_barcode, false), - BoolSetting("screenlock", R.string.preference_screenlock_title, false), - BoolSetting("quickedit", R.string.preference_quickedit, false), - BoolSetting("use_filters", R.string.preference_usefilters_title, false), - BoolSetting("holosearch", R.string.preference_holo_search_title, true), - BoolSetting("resetquantity", R.string.preference_reset_quantity, false), - BoolSetting("autocomplete_only_this_list", R.string.preference_complete_by_list_title, false), - BoolSetting("showprice", R.string.preference_showprice_title, true), - ) + val toggles: List = sections.flatMap { it.settings }.filterIsInstance() + + val choices: List = sections.flatMap { it.settings }.filterIsInstance() } diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsScreen.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsScreen.kt index ed40eaa4..d9a2dc34 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsScreen.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.material3.AlertDialog import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,6 +36,7 @@ import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.openintents.shopping.R @Composable fun SettingsRoute(viewModel: SettingsViewModel, onBack: () -> Unit) { @@ -43,6 +46,7 @@ fun SettingsRoute(viewModel: SettingsViewModel, onBack: () -> Unit) { choices = state.choices, onSetBool = viewModel::setBool, onSetChoice = viewModel::setChoice, + onResetAll = viewModel::resetAll, onBack = onBack, ) } @@ -55,14 +59,15 @@ fun SettingsScreen( onSetBool: (String, Boolean) -> Unit, onSetChoice: (String, String) -> Unit, onBack: () -> Unit, + onResetAll: () -> Unit = {}, ) { Scaffold( topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.preferences)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.compose_back)) } } ) @@ -71,23 +76,54 @@ fun SettingsScreen( Column( modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()) ) { - AppSettingsCatalog.choices.forEach { setting -> - ChoiceRow( - title = stringResource(setting.titleRes), - entries = stringArrayResource(setting.entriesRes), - values = stringArrayResource(setting.valuesRes), - selectedValue = choices[setting.key] ?: setting.default, - onSelect = { onSetChoice(setting.key, it) }, + AppSettingsCatalog.sections.forEach { section -> + Text( + stringResource(section.titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp), ) - HorizontalDivider() + section.settings.forEach { setting -> + when (setting) { + is ChoiceSetting -> ChoiceRow( + title = stringResource(setting.titleRes), + entries = stringArrayResource(setting.entriesRes), + values = stringArrayResource(setting.valuesRes), + selectedValue = choices[setting.key] ?: setting.default, + onSelect = { onSetChoice(setting.key, it) }, + ) + is BoolSetting -> SwitchRow( + title = stringResource(setting.titleRes), + checked = bools[setting.key] ?: setting.default, + onCheckedChange = { onSetBool(setting.key, it) }, + ) + } + HorizontalDivider() + } } - AppSettingsCatalog.toggles.forEach { setting -> - SwitchRow( - title = stringResource(setting.titleRes), - checked = bools[setting.key] ?: setting.default, - onCheckedChange = { onSetBool(setting.key, it) }, + var confirmReset by remember { mutableStateOf(false) } + TextButton( + onClick = { confirmReset = true }, + modifier = Modifier.padding(16.dp), + ) { Text(stringResource(R.string.preference_reset_all_settings)) } + if (confirmReset) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = { confirmReset = false }, + title = { Text(stringResource(R.string.preference_reset_all_settings)) }, + confirmButton = { + TextButton(onClick = { + confirmReset = false + onResetAll() + android.widget.Toast.makeText( + context, R.string.preference_reset_all_settings_done, android.widget.Toast.LENGTH_SHORT + ).show() + }) { Text(stringResource(R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = { confirmReset = false }) { Text(stringResource(R.string.cancel)) } + }, ) - HorizontalDivider() } } } @@ -131,7 +167,7 @@ private fun ChoiceRow( Text(title, style = MaterialTheme.typography.bodyLarge) Text(selectedLabel, style = MaterialTheme.typography.bodySmall) } - TextButton(onClick = { expanded = true }) { Text("Change") } + TextButton(onClick = { expanded = true }) { Text(stringResource(R.string.compose_change)) } } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { entries.forEachIndexed { index, label -> diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsViewModel.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsViewModel.kt index a17e02ea..cbbd07c6 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsViewModel.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/compose/settings/SettingsViewModel.kt @@ -35,6 +35,13 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel( load() } + /** "Reset all settings": every setting back to its default. */ + fun resetAll() { + AppSettingsCatalog.toggles.forEach { repository.setBoolean(it.key, it.default) } + AppSettingsCatalog.choices.forEach { repository.setString(it.key, it.default) } + load() + } + private fun load() { _state.value = SettingsUiState( bools = AppSettingsCatalog.toggles.associate { it.key to repository.getBoolean(it.key, it.default) }, diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/dialog/ThemeDialog.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/dialog/ThemeDialog.kt index e9e39d0e..c2f4b168 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/dialog/ThemeDialog.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/dialog/ThemeDialog.kt @@ -102,7 +102,8 @@ open class ThemeDialog : AlertDialog, OnClickListener, OnCancelListener, OnItemC ll.setPadding(20, 10, 20, 10) ll.addView(b, lp) ll.gravity = Gravity.CENTER - mListView.addFooterView(ll) + // not selectable: it is not a theme entry (see getSelectedTheme()) + mListView.addFooterView(ll, null, false) mCheckBox = view.findViewById(R.id.check1) @@ -223,7 +224,7 @@ open class ThemeDialog : AlertDialog, OnClickListener, OnCancelListener, OnItemC private fun getSelectedTheme(): String? { val pos = mListView.checkedItemPosition - return if (pos != ListView.INVALID_POSITION) { + return if (pos in mListInfo.indices) { val ti = mListInfo[pos] ti.styleName } else { diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/ShoppingItemsView.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/ShoppingItemsView.kt index 632caaac..4e7ea42a 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/ShoppingItemsView.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/ShoppingItemsView.kt @@ -361,7 +361,8 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { */ fun fillItems(activity: Activity, listId: Long) { - mCursorItems = null + // Keep the previous cursor until onLoadFinished() swaps in the new one: + // the adapter still shows (and lets the user click) the old rows. mCursorActivity = activity mListId = listId @@ -747,6 +748,10 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { * @param on if true all want_to_buy items are set to bought, if false all bought items are set to want_to_buy */ fun toggleAllItems(on: Boolean) { + val cursor = mCursorItems + if (cursor == null || cursor.isClosed) { + return + } val op_type = if (on) SnackbarUndoMultipleItemStatusOperation.MARK_ALL else SnackbarUndoMultipleItemStatusOperation.UNMARK_ALL var op: SnackbarUndoMultipleItemStatusOperation? = null @@ -757,7 +762,7 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { ) } - for (i in 0 until mCursorItems!!.getCount()) { + for (i in 0 until cursor.getCount()) { mCursorItems!!.moveToPosition(i) val oldstatus = mCursorItems!! @@ -814,7 +819,12 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { fun toggleItemBought(position: Int) { var shouldFocusItem = false - if (mCursorItems!!.getCount() <= position) { + val cursor = mCursorItems + if (cursor == null || cursor.isClosed) { + Log.e(TAG, "toggle item while list is reloading.") + return + } + if (cursor.getCount() <= position) { Log.e(TAG, "toggle inexistent item. Probably clicked too quickly?") return } @@ -975,11 +985,12 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { if (mSyncSupport!!.isAvailable()) { object : Thread() { override fun run() { - val cursor = createItemsCursor(mListId, null) - Log.d(TAG, "pushing " + cursor!!.getCount() + " items") - cursor.moveToFirst() - while (cursor.moveToNext()) { - mSyncSupport!!.pushListItem(mListId, cursor) + val cursor = createItemsCursor(mListId, null) ?: return + Log.d(TAG, "pushing " + cursor.getCount() + " items") + cursor.use { + while (it.moveToNext()) { + mSyncSupport!!.pushListItem(mListId, it) + } } } }.start() @@ -1816,8 +1827,11 @@ open class ShoppingItemsView : ListView, LoaderManager.LoaderCallbacks { if (debug) { Log.d(TAG, "Click on has_note: $cursorpos") } - mCursorItems!!.moveToPosition(cursorpos) - val note_id = mCursorItems!!.getLong(ShoppingActivity.mStringItemsITEMID) + val cursor = mCursorItems + if (cursor == null || cursor.isClosed || !cursor.moveToPosition(cursorpos)) { + return + } + val note_id = cursor.getLong(ShoppingActivity.mStringItemsITEMID) val uri = ContentUris.withAppendedId(ShoppingContract.Notes.CONTENT_URI, note_id) i.setData(uri) val context = getContext() diff --git a/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/StoreListView.kt b/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/StoreListView.kt index e6945919..d79f17a0 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/StoreListView.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/ui/widget/StoreListView.kt @@ -163,14 +163,16 @@ class StoreListView : ListView { "store_id = ? AND item_id = ?", arrayOf(storeId, mItemId.toString()), null ) - if (existingItems!!.count > 0) { - existingItems.moveToFirst() - val id = existingItems.getLong(cursorColumnID) - cr.delete( - ItemStores.CONTENT_URI.buildUpon() - .appendPath(id.toString()).build(), - null, null - ) + if (existingItems != null) { + if (existingItems.count > 0) { + existingItems.moveToFirst() + val id = existingItems.getLong(cursorColumnID) + cr.delete( + ItemStores.CONTENT_URI.buildUpon() + .appendPath(id.toString()).build(), + null, null + ) + } existingItems.close() } } else { diff --git a/ShoppingList/src/main/java/org/openintents/shopping/widgets/CheckItemsWidget.kt b/ShoppingList/src/main/java/org/openintents/shopping/widgets/CheckItemsWidget.kt index 5051fd3b..c7eab18e 100644 --- a/ShoppingList/src/main/java/org/openintents/shopping/widgets/CheckItemsWidget.kt +++ b/ShoppingList/src/main/java/org/openintents/shopping/widgets/CheckItemsWidget.kt @@ -15,7 +15,6 @@ import org.openintents.shopping.ShoppingActivity import org.openintents.shopping.library.provider.ShoppingContract import org.openintents.shopping.library.provider.ShoppingContract.ContainsFull import org.openintents.shopping.ui.PreferenceActivity -import org.openintents.shopping.ui.widget.ShoppingItemsView open class CheckItemsWidget : AppWidgetProvider() { @@ -132,7 +131,7 @@ open class CheckItemsWidget : AppWidgetProvider() { ) views.setTextViewText( viewId, - cursor.getString(cursor.getColumnIndex(ContainsFull.ITEM_NAME)) + cursor.getString(cursor.getColumnIndexOrThrow(ContainsFull.ITEM_NAME)) ) val intentCheckService = Intent(context, CheckItemsWidget::class.java) @@ -154,6 +153,8 @@ open class CheckItemsWidget : AppWidgetProvider() { */ val intentGoToApp = Intent(context, ShoppingActivity::class.java) intentGoToApp.action = Intent.ACTION_VIEW + // Reuse the open app screen (it switches to this list) instead of stacking another. + intentGoToApp.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP intentGoToApp.data = Uri.withAppendedPath( ShoppingContract.Lists.CONTENT_URI, listId.toString() ) @@ -180,7 +181,7 @@ open class CheckItemsWidget : AppWidgetProvider() { intentPreferences.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) intentPreferences.flags = Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT val pendingIntentPreferences = PendingIntent.getActivity( - context, 0, intentPreferences, + context, widgetId, intentPreferences, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.button_go_to_preferences, pendingIntentPreferences) @@ -249,14 +250,14 @@ open class CheckItemsWidget : AppWidgetProvider() { @JvmStatic fun fillItems(context: Context, listId: Long): Cursor { val sortOrder = PreferenceActivity.getSortOrderFromPrefs( - context, ShoppingItemsView.MODE_IN_SHOP + context, PreferenceActivity.MODE_IN_SHOP ) val selection = "list_id = ? AND " + ShoppingContract.Contains.STATUS + " == " + ShoppingContract.Status.WANT_TO_BUY return context.contentResolver.query( - ContainsFull.CONTENT_URI, org.openintents.shopping.ui.ShoppingActivity.PROJECTION_ITEMS, + ContainsFull.CONTENT_URI, arrayOf(ContainsFull._ID, ContainsFull.ITEM_NAME), selection, arrayOf(listId.toString()), sortOrder )!! } diff --git a/ShoppingList/src/main/java/org/openintents/util/MenuIntentOptionsWithIcons.kt b/ShoppingList/src/main/java/org/openintents/util/MenuIntentOptionsWithIcons.kt index eb6aaf19..58217232 100644 --- a/ShoppingList/src/main/java/org/openintents/util/MenuIntentOptionsWithIcons.kt +++ b/ShoppingList/src/main/java/org/openintents/util/MenuIntentOptionsWithIcons.kt @@ -28,13 +28,18 @@ class MenuIntentOptionsWithIcons( outSpecificItems: Array? ): Int { val pm = mContext.packageManager - val lri = pm.queryIntentActivityOptions(caller, specifics, intent, 0) + val lri: List? = + pm.queryIntentActivityOptions(caller, specifics, intent, 0) val N = lri?.size ?: 0 if ((flags and Menu.FLAG_APPEND_TO_GROUP) == 0) { mMenu.removeGroup(group) } for (i in 0 until N) { val ri = lri!![i] + // Own activities (e.g. Convert CSV) are already in the menu explicitly. + if (caller != null && ri.activityInfo.packageName == caller.packageName) { + continue + } val rintent = Intent( if (ri.specificIndex < 0) intent else specifics!![ri.specificIndex] ) diff --git a/ShoppingList/src/main/res/drawable-hdpi/layout_choice_actionbar.png b/ShoppingList/src/main/res/drawable-hdpi/layout_choice_actionbar.png deleted file mode 100755 index a2dc1e3a..00000000 Binary files a/ShoppingList/src/main/res/drawable-hdpi/layout_choice_actionbar.png and /dev/null differ diff --git a/ShoppingList/src/main/res/drawable-hdpi/layout_choice_bottom.png b/ShoppingList/src/main/res/drawable-hdpi/layout_choice_bottom.png deleted file mode 100755 index 907bfc63..00000000 Binary files a/ShoppingList/src/main/res/drawable-hdpi/layout_choice_bottom.png and /dev/null differ diff --git a/ShoppingList/src/main/res/drawable/ic_barcode.xml b/ShoppingList/src/main/res/drawable/ic_barcode.xml new file mode 100644 index 00000000..71844b5a --- /dev/null +++ b/ShoppingList/src/main/res/drawable/ic_barcode.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/ShoppingList/src/main/res/layout/dialog_layout_choice.xml b/ShoppingList/src/main/res/layout/dialog_layout_choice.xml deleted file mode 100644 index b64691da..00000000 --- a/ShoppingList/src/main/res/layout/dialog_layout_choice.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ShoppingList/src/main/res/raw/recent_changes.txt b/ShoppingList/src/main/res/raw/recent_changes.txt index 3bc817bd..a32fbea2 100644 --- a/ShoppingList/src/main/res/raw/recent_changes.txt +++ b/ShoppingList/src/main/res/raw/recent_changes.txt @@ -1,4 +1,15 @@ ---------------- +release 2.3.0 +date: 2026-09-24 + +- New main screen: tap to check, long-press to edit. All list themes kept. +- Add items by scanning barcodes (names from Open Food Facts). +- Compact view, fast scroll, search/add in the top bar. +- Updated for Android 15; CSV via the system file picker. +- Fixed crashes (CSV, widget, automation, translations) and wrong item moved after rotating. +- Backups include the latest changes. +- Smaller app. +---------------- release 2.2.1 date: 2020-06-13 diff --git a/ShoppingList/src/main/res/values-ar/strings-convertcsv.xml b/ShoppingList/src/main/res/values-ar/strings-convertcsv.xml index 0e095aae..63a60e36 100644 --- a/ShoppingList/src/main/res/values-ar/strings-convertcsv.xml +++ b/ShoppingList/src/main/res/values-ar/strings-convertcsv.xml @@ -24,7 +24,7 @@ المفكرة OI - s% إطلاق وحدد القائمة ;gt + %s إطلاق وحدد القائمة ;gt - "قائمة التسوق OI + قائمة التسوق OI عنصر جديد قائمة جديدة diff --git a/ShoppingList/src/main/res/values-fa/strings.xml b/ShoppingList/src/main/res/values-fa/strings.xml index ee2d603a..5eed38f7 100644 --- a/ShoppingList/src/main/res/values-fa/strings.xml +++ b/ShoppingList/src/main/res/values-fa/strings.xml @@ -95,7 +95,7 @@ %s - خرید - فروشگاهی-s% + فروشگاهی-%s ویرایش فروشگاه مورد برچسب ها @@ -118,10 +118,10 @@ بررسی شد: %s - اولویت 1 :s% - اولویت 1-2 :s% - اولویت 1-3 :s% - اولویت 1-4 :s% + اولویت 1 :%s + اولویت 1-2 :%s + اولویت 1-3 :%s + اولویت 1-4 :%s آیا شما مطمئن هستید که می خواهید به طور دائم حذف این آیتم (\"%s\")؟ حذف @@ -371,5 +371,5 @@ صفحه قبلی صفحه بعدی یک لیست را انتخاب کنید - هیچ مطلبی در صفحه d% + هیچ مطلبی در صفحه %d diff --git a/ShoppingList/src/main/res/values-sd/strings-convertcsv.xml b/ShoppingList/src/main/res/values-sd/strings-convertcsv.xml index 7d3d70ef..9633a281 100644 --- a/ShoppingList/src/main/res/values-sd/strings-convertcsv.xml +++ b/ShoppingList/src/main/res/values-sd/strings-convertcsv.xml @@ -38,7 +38,7 @@ CSV खे खरीदारी सूचीमें परिवर्तित कयो सद्यूं नोट्स परिवर्तित कयो सभी खरीदारी सूचियन खे परिवर्तित कयो - s\' सूची खे परिवर्तित कयो%\' + \'%s\' सूची खे परिवर्तित कयो फ़ाइल जो पथ आयात निर्यात diff --git a/ShoppingList/src/main/res/values-sd/strings.xml b/ShoppingList/src/main/res/values-sd/strings.xml index b655ece7..1ccbcd46 100644 --- a/ShoppingList/src/main/res/values-sd/strings.xml +++ b/ShoppingList/src/main/res/values-sd/strings.xml @@ -85,13 +85,13 @@ खरीदारी - s - आइटम चुन्द्यो% + %s - आइटम चुन्द्यो - s - खरीदारी % + %s - खरीदारी - s - स्टोर्स% + %s - स्टोर्स आइटम स्टोर खे सम्पादित कयो टैग्स diff --git a/ShoppingList/src/main/res/values-v35/styles.xml b/ShoppingList/src/main/res/values-v35/styles.xml new file mode 100644 index 00000000..c9e65bd3 --- /dev/null +++ b/ShoppingList/src/main/res/values-v35/styles.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/ShoppingList/src/main/res/values/strings_compose.xml b/ShoppingList/src/main/res/values/strings_compose.xml new file mode 100644 index 00000000..e52ec451 --- /dev/null +++ b/ShoppingList/src/main/res/values/strings_compose.xml @@ -0,0 +1,34 @@ + + + + Open lists + More options + Lists + Show checked items + Import CSV… + Export CSV… + List name + Create + Rename + Save + Done + Add store + Remove %1$s + Per-store prices + All + Invalid price + Back + Change + Clear + Compact view + Scan barcode + Scanning barcodes needs a barcode scanner app, for example Binary Eye. + Install + Unknown product %1$s + No connection, so the product could not be looked up. Enter its name: + Show barcode scan button + Look up scanned products on Open Food Facts + + Milk + Bread + diff --git a/ShoppingList/src/main/res/values/strings_not_for_translation.xml b/ShoppingList/src/main/res/values/strings_not_for_translation.xml index f0a992c9..2a912900 100644 --- a/ShoppingList/src/main/res/values/strings_not_for_translation.xml +++ b/ShoppingList/src/main/res/values/strings_not_for_translation.xml @@ -150,7 +150,7 @@ *************************** --> Subject - % Complete + % Complete Categories Tags diff --git a/ShoppingList/src/main/res/values/styles.xml b/ShoppingList/src/main/res/values/styles.xml index 673adbdf..7b694f88 100644 --- a/ShoppingList/src/main/res/values/styles.xml +++ b/ShoppingList/src/main/res/values/styles.xml @@ -1,15 +1,23 @@ - + diff --git a/ShoppingList/src/main/res/xml/preferences.xml b/ShoppingList/src/main/res/xml/preferences.xml index e75f665f..70bfe240 100644 --- a/ShoppingList/src/main/res/xml/preferences.xml +++ b/ShoppingList/src/main/res/xml/preferences.xml @@ -33,9 +33,6 @@ android:key="sortorder" android:summary="@string/preference_sortorder_summary" android:title="@string/preference_sortorder_title" /> - - - - - - - - - - - - - diff --git a/ShoppingList/src/playInternet/java/org/openintents/shopping/OptionalDependencies.java b/ShoppingList/src/playInternet/java/org/openintents/shopping/OptionalDependencies.java deleted file mode 100644 index 4a7b36df..00000000 --- a/ShoppingList/src/playInternet/java/org/openintents/shopping/OptionalDependencies.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.openintents.shopping; - -import android.app.Activity; - -import com.pollfish.constants.Position; -import com.pollfish.main.PollFish; - -public class OptionalDependencies extends BaseOptionalDependencies { - @Override - public void onResumeShoppingActivity(final Activity context) { - PollFish.init(context, BuildConfig.KEY_POLLFISH, Position.MIDDLE_RIGHT, 0); - } -} diff --git a/ShoppingList/src/playInternet/java/org/openintents/shopping/sync/GooglePlaySyncSupport.java b/ShoppingList/src/playInternet/java/org/openintents/shopping/sync/GooglePlaySyncSupport.java deleted file mode 100644 index 1c2007ef..00000000 --- a/ShoppingList/src/playInternet/java/org/openintents/shopping/sync/GooglePlaySyncSupport.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.openintents.shopping.sync; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; - -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GooglePlayServicesUtil; -import com.google.android.gms.common.api.GoogleApiClient; -import com.google.android.gms.common.api.PendingResult; -import com.google.android.gms.wearable.DataApi; -import com.google.android.gms.wearable.MessageApi; -import com.google.android.gms.wearable.Node; -import com.google.android.gms.wearable.NodeApi; -import com.google.android.gms.wearable.PutDataMapRequest; -import com.google.android.gms.wearable.PutDataRequest; -import com.google.android.gms.wearable.Wearable; - -import org.openintents.shopping.SyncSupport; -import org.openintents.shopping.library.provider.ShoppingContract; - -public class GooglePlaySyncSupport implements SyncSupport { - - private static final String TAG = GooglePlaySyncSupport.class.getSimpleName(); - GoogleApiClient mGoogleApiClient; - private boolean syncEnabled = true; - - public GooglePlaySyncSupport(Context context) { - int availability = GooglePlayServicesUtil.isGooglePlayServicesAvailable(context); - if (availability == ConnectionResult.SUCCESS) { - mGoogleApiClient = new GoogleApiClient.Builder(context) - .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { - @Override - public void onConnected(Bundle connectionHint) { - - } - - @Override - public void onConnectionSuspended(int cause) { - - } - }) - .addOnConnectionFailedListener(new GoogleApiClient.OnConnectionFailedListener() { - @Override - public void onConnectionFailed(ConnectionResult result) { - - } - }) - .addApi(Wearable.API) - .build(); - mGoogleApiClient.connect(); - } - } - - @Override - public boolean isAvailable() { - return mGoogleApiClient != null && mGoogleApiClient.isConnected(); - } - - @Override - public boolean isSyncEnabled() { - return syncEnabled; - } - - @Override - public void setSyncEnabled(boolean enableSync) { - syncEnabled = enableSync; - } - - @Override - public void pushListItem(long listId, Cursor cursor) { - String id = cursor.getString(cursor.getColumnIndex(ShoppingContract.ContainsFull._ID)); - String listIdString = String.valueOf(listId); - PutDataMapRequest dataMap = PutDataMapRequest.create("/" + listIdString + "/items/" + id); - putString(dataMap, cursor, ShoppingContract.ContainsFull.ITEM_NAME); - putString(dataMap, cursor, ShoppingContract.ContainsFull.QUANTITY); - putString(dataMap, cursor, ShoppingContract.ContainsFull.ITEM_UNITS); - putString(dataMap, cursor, ShoppingContract.ContainsFull.STATUS); - putString(dataMap, cursor, ShoppingContract.ContainsFull.ITEM_TAGS); - sendRequest(dataMap, listIdString); - } - - public void pushList(Cursor cursor) { - String id = cursor.getString(cursor.getColumnIndex(ShoppingContract.Lists._ID)); - PutDataMapRequest dataMap = PutDataMapRequest.create("/lists/" + id); - putString(dataMap, cursor, ShoppingContract.Lists.NAME); - putString(dataMap, cursor, ShoppingContract.Lists.ITEMS_SORT); - putString(dataMap, cursor, ShoppingContract.Lists.STORE_FILTER); - sendRequest(dataMap, null); - } - - @Override - public void updateListItem(long listId, Uri itemUri, ContentValues values) { - String id = itemUri.getLastPathSegment(); - PutDataMapRequest request = PutDataMapRequest.create("/" + listId + "/items/" + id); - for (String key : values.keySet()) { - String value = values.getAsString(key); - request.getDataMap().putString(key, value); - } - sendRequest(request, String.valueOf(listId)); - } - - - private void sendRequest(PutDataMapRequest dataMap, String listIdToShow) { - PutDataRequest request = dataMap.asPutDataRequest(); - PendingResult pendingResult = Wearable.DataApi - .putDataItem(mGoogleApiClient, request); - pendingResult.await(); - - if (listIdToShow != null) { - NodeApi.GetConnectedNodesResult nodes = Wearable.NodeApi.getConnectedNodes(mGoogleApiClient).await(); - Node node = nodes.getNodes().get(0); - if (node != null) { - MessageApi.SendMessageResult result = Wearable.MessageApi.sendMessage(mGoogleApiClient, node.getId(), "items", listIdToShow.getBytes()).await(); - Log.d(TAG, "" + result.getStatus()); - } else { - Log.d(TAG, "no android wear"); - } - } - } - - private void putString(PutDataMapRequest request, Cursor cursor, String columnName) { - String value = cursor.getString(cursor.getColumnIndex(columnName)); - request.getDataMap().putString(columnName, value); - } -} diff --git a/ShoppingList/src/test/java/org/openintents/shopping/ScreenshotTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/ScreenshotTest.kt new file mode 100644 index 00000000..0781302b --- /dev/null +++ b/ShoppingList/src/test/java/org/openintents/shopping/ScreenshotTest.kt @@ -0,0 +1,111 @@ +package org.openintents.shopping + +import android.app.Activity +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openintents.shopping.data.ItemEdit +import org.openintents.shopping.data.ListTheme +import org.openintents.shopping.data.ProviderShoppingRepository +import org.openintents.shopping.ui.PreferenceActivity +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Renders the UI with the same sample data to PNGs in + * ShoppingList/build/screenshots/. Skipped unless run with -Pscreenshots, e.g. + * ./gradlew :ShoppingList:testPlayDebugUnitTest --tests "*ScreenshotTest.composeUiClassic" -Pscreenshots + * Run one test per Gradle invocation: several activities in one run can leave + * the capture of later ones mid-animation (e.g. with the drawer shown). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ScreenshotTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val repo by lazy { ProviderShoppingRepository(context) } + private var listId = -1L + + @Before + fun setUp() { + assumeTrue(System.getProperty("screenshots") != null) + @Suppress("DEPRECATION") + android.preference.PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString("sortorder", "0").commit() // unchecked first + listId = repo.createList("Groceries") + repo.createList("Hardware store") + listOf("Milk", "Bread", "Eggs", "Apples", "Coffee", "Butter").forEach { repo.addItem(listId, it) } + val items = repo.getItems(listId) + items.first { it.name == "Coffee" }.let { + repo.updateItem(it, ItemEdit("Coffee", "2", 499, "packs", "1", null)) + } + items.first { it.name == "Milk" }.let { + repo.updateItem(it, ItemEdit("Milk", "1", 119, "l", null, null)) + } + repo.toggleItemBought(items.first { it.name == "Bread" }) + repo.toggleItemBought(items.first { it.name == "Eggs" }) + repo.setActiveList(listId) + } + + @Test + fun composeUi() = capture(ShoppingActivity::class.java, "compose-ui.png") + + @Test + fun composeUiTopBar() { + @Suppress("DEPRECATION") + android.preference.PreferenceManager.getDefaultSharedPreferences(context) + .edit().putBoolean("holosearch", true).commit() + capture(ShoppingActivity::class.java, "compose-ui-topbar.png") + } + + @Test + fun composeUiCompact() { + @Suppress("DEPRECATION") + android.preference.PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean("compact", true).putBoolean("fastscroll", true).commit() + listOf( + "Bananas", "Carrots", "Cheese", "Chocolate", "Cucumber", "Flour", "Garlic", "Ham", + "Juice", "Lemons", "Onions", "Pasta", "Pepper", "Potatoes", "Rice", "Salad", "Salt", + "Soap", "Sugar", "Tea", "Tomatoes", "Toothpaste", "Water", "Yoghurt", + ).forEach { repo.addItem(listId, it) } + capture(ShoppingActivity::class.java, "compose-ui-compact.png") + } + + @Test + fun composeUiClassic() { + repo.setListTheme(listId, ListTheme.CLASSIC) + capture(ShoppingActivity::class.java, "compose-ui-classic.png") + } + + @Test + fun composeUiBugdroid() { + repo.setListTheme(listId, ListTheme.ANDROID) + capture(ShoppingActivity::class.java, "compose-ui-bugdroid.png") + } + + private fun capture(cls: Class, fileName: String) { + val activity = Robolectric.buildActivity(cls).setup().get() + // Let loaders / coroutines (real background threads) finish and redraw. + repeat(60) { + Thread.sleep(100) + shadowOf(Looper.getMainLooper()).idle() + } + val view = activity.window.decorView + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + val dir = File("build/screenshots").apply { mkdirs() } + File(dir, fileName).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } +} diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/FakeShoppingRepository.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/FakeShoppingRepository.kt index e1d2902d..703efab7 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/data/FakeShoppingRepository.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/FakeShoppingRepository.kt @@ -13,15 +13,33 @@ class FakeShoppingRepository : ShoppingRepository { private val storesByList = mutableMapOf>() private var nextId = 1L + var activeListId: Long? = null + override fun getDefaultListId(): Long { if (lists.isEmpty()) createList("My list") - return lists.first().id + return activeListId?.takeIf { id -> lists.any { it.id == id } } ?: lists.first().id + } + + override fun setActiveList(listId: Long) { + activeListId = listId } override fun getLists(): List = lists.toList() - override fun getItems(listId: Long): List = - itemsByList[listId].orEmpty().filter { it.status != Status.REMOVED_FROM_LIST } + private val sortByList = mutableMapOf() + + override fun getSortOrder(listId: Long): Int = sortByList[listId] ?: 0 + + override fun setSortOrder(listId: Long, sortOrder: Int) { sortByList[listId] = sortOrder } + + /** Implements the two basic legacy sort orders: 0 unchecked first + name, 1 name. */ + override fun getItems(listId: Long): List { + val items = itemsByList[listId].orEmpty().filter { it.status != Status.REMOVED_FROM_LIST } + return when (getSortOrder(listId)) { + 1 -> items.sortedBy { it.name.lowercase() } + else -> items.sortedWith(compareBy({ it.status }, { it.name.lowercase() })) + } + } override fun getAllListItems(listId: Long): List = itemsByList[listId].orEmpty().toList() @@ -43,6 +61,19 @@ class FakeShoppingRepository : ShoppingRepository { return id } + private val barcodes = mutableMapOf() + + override fun addItems(listId: Long, items: List): Int = + items.count { item -> + item.barcode?.let { barcodes[it] = item.name.trim() } + addItem(listId, item.name) >= 0 + } + + override fun getItemNameForBarcode(barcode: String): String? = barcodes[barcode] + + override fun getItemStatus(containsId: Long): Long? = + itemsByList.values.flatten().firstOrNull { it.containsId == containsId }?.status + override fun getItemNameSuggestions(): List = itemsByList.values.flatten() .map { it.name } @@ -83,6 +114,38 @@ class FakeShoppingRepository : ShoppingRepository { } } + private val filtersByList = mutableMapOf() + + override fun getListFilters(listId: Long): ListFilters = filtersByList[listId] ?: ListFilters() + + override fun setStoreFilter(listId: Long, storeId: Long?) { + filtersByList[listId] = getListFilters(listId).copy(storeId = storeId) + } + + override fun setTagFilter(listId: Long, tag: String?) { + filtersByList[listId] = getListFilters(listId).copy(tag = tag) + } + + override fun getListTags(listId: Long): List = + itemsByList[listId].orEmpty().flatMap { it.tags.orEmpty().split(',') } + .map { it.trim() }.filter { it.isNotEmpty() }.distinct().sorted() + + override fun moveItem(item: ShoppingItem, targetListId: Long) { + itemsByList.values.forEach { items -> items.removeAll { it.containsId == item.containsId } } + itemsByList.getOrPut(targetListId) { mutableListOf() }.add(item) + } + + override fun copyItem(item: ShoppingItem): Long? { + val id = nextId++ + itemsByList.values.firstOrNull { items -> items.any { it.containsId == item.containsId } } + ?.add(item.copy(containsId = id, itemId = id)) + return id + } + + override fun deleteItem(listId: Long, item: ShoppingItem) { + itemsByList[listId]?.removeAll { it.containsId == item.containsId } + } + private val themeByList = mutableMapOf() override fun getListTheme(listId: Long): ListTheme = themeByList[listId] ?: ListTheme.DEFAULT diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/ItemArrangementTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/ItemArrangementTest.kt index a8701264..321f9e0f 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/data/ItemArrangementTest.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/ItemArrangementTest.kt @@ -4,45 +4,24 @@ import org.junit.Assert.assertEquals import org.junit.Test import org.openintents.shopping.library.provider.ShoppingContract.Status -/** Pure JVM tests for sort + filter of the item list. */ +/** Pure JVM tests for filtering the item list. */ class ItemArrangementTest { private fun item(name: String, bought: Boolean) = ShoppingItem(0, 0, name, if (bought) Status.BOUGHT else Status.WANT_TO_BUY, null, null, null, null) @Test - fun alphabetical_sortsCaseInsensitive() { + fun keepsTheRepositoryOrder() { val out = arrangeItems( - listOf(item("banana", false), item("Apple", false), item("cherry", false)), - SortMode.ALPHABETICAL, hideChecked = false + listOf(item("banana", false), item("Apple", true), item("cherry", false)), + hideChecked = false ) - assertEquals(listOf("Apple", "banana", "cherry"), out.map { it.name }) - } - - @Test - fun uncheckedFirst_putsBoughtLastThenAlphabetical() { - val out = arrangeItems( - listOf(item("milk", true), item("eggs", false), item("bread", false)), - SortMode.UNCHECKED_FIRST, hideChecked = false - ) - assertEquals(listOf("bread", "eggs", "milk"), out.map { it.name }) + assertEquals(listOf("banana", "Apple", "cherry"), out.map { it.name }) } @Test fun hideChecked_dropsBoughtItems() { - val out = arrangeItems( - listOf(item("milk", true), item("eggs", false)), - SortMode.ALPHABETICAL, hideChecked = true - ) + val out = arrangeItems(listOf(item("milk", true), item("eggs", false)), hideChecked = true) assertEquals(listOf("eggs"), out.map { it.name }) } - - @Test - fun hideCheckedFalse_keepsEverything() { - val out = arrangeItems( - listOf(item("milk", true), item("eggs", false)), - SortMode.ALPHABETICAL, hideChecked = false - ) - assertEquals(2, out.size) - } } diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/ListThemeTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/ListThemeTest.kt new file mode 100644 index 00000000..fdf2a8e0 --- /dev/null +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/ListThemeTest.kt @@ -0,0 +1,42 @@ +package org.openintents.shopping.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The Compose UI and the legacy UI must read each other's stored list themes. */ +class ListThemeTest { + + @Test + fun fromName_readsLegacyNumericValues() { + assertEquals(ListTheme.DEFAULT, ListTheme.fromName("1")) + assertEquals(ListTheme.CLASSIC, ListTheme.fromName("2")) + assertEquals(ListTheme.ANDROID, ListTheme.fromName("3")) + } + + @Test + fun fromName_readsLegacyStyleResourceNames() { + assertEquals(ListTheme.DEFAULT, ListTheme.fromName("org.openintents.shopping:style/Theme.ShoppingList")) + assertEquals( + ListTheme.CLASSIC, + ListTheme.fromName("org.openintents.shopping:style/Theme.ShoppingList.Classic") + ) + assertEquals( + ListTheme.ANDROID, + ListTheme.fromName("org.openintents.shopping:style/Theme.ShoppingList.Android") + ) + } + + @Test + fun fromName_readsEnumNamesAndFallsBackToDefault() { + assertEquals(ListTheme.CLASSIC, ListTheme.fromName("CLASSIC")) + assertEquals(ListTheme.ANDROID, ListTheme.fromName("ANDROID")) + assertEquals(ListTheme.DEFAULT, ListTheme.fromName(null)) + assertEquals(ListTheme.DEFAULT, ListTheme.fromName("")) + assertEquals(ListTheme.DEFAULT, ListTheme.fromName("com.other.app:style/Fancy")) + } + + @Test + fun storedValue_roundTrips() { + ListTheme.entries.forEach { assertEquals(it, ListTheme.fromName(it.storedValue)) } + } +} diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/OpenFoodFactsLookupTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/OpenFoodFactsLookupTest.kt new file mode 100644 index 00000000..abe04826 --- /dev/null +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/OpenFoodFactsLookupTest.kt @@ -0,0 +1,54 @@ +package org.openintents.shopping.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Parsing of Open Food Facts API v2 responses (Robolectric: real org.json). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class OpenFoodFactsLookupTest { + + @Test + fun textThatIsNoBarcode_isNotLookedUp() { + // Returns without going online (no network in unit tests). + assertEquals(LookupResult.NotFound, OpenFoodFactsLookup("test").lookup("hello")) + } + + @Test + fun parsesLocalizedNameAndBrand() { + val json = """{"code":"3017620422003","status":1,"product": + {"product_name":"Nutella","product_name_de":"Nutella Nuss-Nougat-Creme","brands":"Ferrero, Nutella"}}""" + assertEquals( + "Nutella Nuss-Nougat-Creme (Ferrero)", + OpenFoodFactsLookup.parseProductName(json, "de") + ) + assertEquals("Nutella (Ferrero)", OpenFoodFactsLookup.parseProductName(json, "fr")) + } + + @Test + fun addsTheBrandWhenTheNameLacksIt() { + val json = """{"status":1,"product":{"product_name":"Hazelnut spread","brands":"Ferrero"}}""" + assertEquals("Hazelnut spread (Ferrero)", OpenFoodFactsLookup.parseProductName(json, "en")) + } + + @Test + fun unknownProductOrBadJson_isNull() { + assertNull(OpenFoodFactsLookup.parseProductName("""{"status":0,"status_verbose":"product not found"}""", "en")) + assertNull(OpenFoodFactsLookup.parseProductName("""{"status":1,"product":{"brands":"X"}}""", "en")) + assertNull(OpenFoodFactsLookup.parseProductName("", "en")) + } + + @Test + fun isBarcode() { + assertTrue(OpenFoodFactsLookup.isBarcode("3017620422003")) + assertTrue(OpenFoodFactsLookup.isBarcode("96385074")) + assertFalse(OpenFoodFactsLookup.isBarcode("12ab")) + assertFalse(OpenFoodFactsLookup.isBarcode("../../etc")) + } +} diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingRepositoryTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingRepositoryTest.kt index 126f2a8d..9ceeee1b 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingRepositoryTest.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingRepositoryTest.kt @@ -44,6 +44,31 @@ class ShoppingRepositoryTest { assertTrue(repo.getItems(id).any { it.name == "Milk" }) } + @Test + fun addItem_reusesTheCatalogueItemFromAnotherList() { + // Like the legacy UI: "Milk" on a second list is the same item (same + // price/tags/store prices), not a duplicate catalogue entry. + val a = repo.createList("ReuseA") + val b = repo.createList("ReuseB") + val first = repo.addItem(a, "Milk") + val second = repo.addItem(b, "milk") + assertEquals(first, second) + assertTrue(repo.getItems(b).any { it.itemId == first }) + } + + @Test + fun getDefaultListId_fallsBackWhenLastUsedListWasDeleted() { + val a = repo.createList("LastUsedA") + val b = repo.createList("LastUsedB") + repo.setActiveList(b) + assertEquals(b, repo.getDefaultListId()) + + repo.deleteList(b) + val id = repo.getDefaultListId() + assertTrue(repo.getLists().any { it.id == id }) + assertEquals(a, id) + } + @Test fun createList_thenItAppears() { val id = repo.createList("Groceries") @@ -122,7 +147,7 @@ class ShoppingRepositoryTest { val removed = repo.cleanupList(listId) - assertEquals(1, removed) + assertEquals(1, removed.size) val names = repo.getItems(listId).map { it.name } assertFalse(names.contains("Milk")) assertTrue(names.contains("Eggs")) @@ -248,4 +273,95 @@ class ShoppingRepositoryTest { assertTrue(repo.getItems(listA).any { it.name == "OnlyA" }) assertFalse(repo.getItems(listB).any { it.name == "OnlyA" }) } + + @Test + fun addItems_fromAnotherApp_keepsQuantityAndPrice() { + val listId = repo.createList("Shared") + val added = repo.addItems( + listId, + listOf(NewItem("Tea", "2", "1.50"), NewItem(" "), NewItem("Honey", null, "abc")) + ) + assertEquals(2, added) + val tea = repo.getItems(listId).single { it.name == "Tea" } + assertEquals("2", tea.quantity) + assertEquals(150L, tea.priceCents) + // An unparsable price is ignored instead of failing the item. + assertEquals(null, repo.getItems(listId).single { it.name == "Honey" }.priceCents) + } + + @Test + fun toggleItemBought_usesTheStoredStatus() { + val listId = repo.createList("DoubleTap") + repo.addItem(listId, "Salt") + val stale = repo.getItems(listId).single() + repo.toggleItemBought(stale) + // A second tap on the same (stale) row flips it back. + repo.toggleItemBought(stale) + assertEquals(Status.WANT_TO_BUY, repo.getItems(listId).single().status) + } + + @Test + fun tagFilter_hidesOtherItemsUntilCleared() { + val listId = repo.createList("Filtered") + repo.addItem(listId, "Pepper") + repo.addItem(listId, "Soap") + val soap = repo.getItems(listId).single { it.name == "Soap" } + repo.updateItem(soap, ItemEdit("Soap", null, null, null, null, "drugstore, bath")) + assertEquals(listOf("bath", "drugstore"), repo.getListTags(listId)) + + repo.setTagFilter(listId, "drugstore") + assertEquals(ListFilters(tag = "drugstore"), repo.getListFilters(listId)) + assertEquals(listOf("Soap"), repo.getItems(listId).map { it.name }) + + repo.setTagFilter(listId, null) + assertEquals(2, repo.getItems(listId).size) + } + + @Test + fun moveCopyAndDeleteItems() { + val a = repo.createList("MoveA") + val b = repo.createList("MoveB") + repo.addItem(a, "Rice") + repo.addItem(a, "Beans") + val rice = repo.getItems(a).single { it.name == "Rice" } + repo.moveItem(rice, b) + assertEquals(listOf("Rice"), repo.getItems(b).map { it.name }) + assertFalse(repo.getItems(a).any { it.name == "Rice" }) + + val beans = repo.getItems(a).single() + val copy = repo.copyItem(beans) + assertTrue(copy != null && repo.getItems(a).any { it.containsId == copy }) + assertEquals(2, repo.getItems(a).size) + + repo.deleteItem(a, beans) + assertFalse(repo.getAllListItems(a).any { it.containsId == beans.containsId }) + } + + @Test + fun markAllAndCleanup_canBeUndone() { + val listId = repo.createList("Undo") + repo.addItem(listId, "x") + repo.addItem(listId, "y") + val marked = repo.markAllItems(listId, true) + assertEquals(2, marked.size) + val cleaned = repo.cleanupList(listId) + assertTrue(repo.getItems(listId).isEmpty()) + repo.restore(cleaned) + assertTrue(repo.getItems(listId).all { it.isBought }) + repo.restore(marked) + assertTrue(repo.getItems(listId).none { it.isBought }) + } + + @Test + fun exportCsv_marksRemovedItems() { + val listId = repo.createList("ExportRemoved") + repo.addItem(listId, "Kept") + repo.addItem(listId, "Gone") + repo.removeItem(listId, repo.getItems(listId).single { it.name == "Gone" }) + val out = java.io.StringWriter() + repo.exportCsv(out) + val csv = out.toString() + assertTrue(csv, csv.lines().any { it.contains("Gone") && it.contains("-1") }) + assertTrue(csv, csv.lines().any { it.contains("Kept") && it.contains(",0,") }) + } } diff --git a/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingTotalsTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingTotalsTest.kt index 14bec672..bcb42c23 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingTotalsTest.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/data/ShoppingTotalsTest.kt @@ -71,4 +71,12 @@ class ShoppingTotalsTest { val t = computeTotals(listOf(item(100, null, Status.REMOVED_FROM_LIST))) assertEquals(0L, t.allCents) } + + @Test + fun lineCents_multipliesPriceByQuantity() { + val item = ShoppingItem(1, 1, "Coffee", Status.WANT_TO_BUY, "2", 499, null, null) + assertEquals(998L, lineCents(item)) + assertEquals(499L, lineCents(item.copy(quantity = null))) + assertEquals(null, lineCents(item.copy(priceCents = null))) + } } diff --git a/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/ShoppingListViewModelTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/ShoppingListViewModelTest.kt index f98bbe4a..acdd56c5 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/ShoppingListViewModelTest.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/ShoppingListViewModelTest.kt @@ -13,10 +13,13 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.openintents.shopping.data.FakeSettingsRepository import org.openintents.shopping.data.FakeShoppingRepository import org.openintents.shopping.data.ItemEdit import org.openintents.shopping.data.ListMode import org.openintents.shopping.data.ListTheme +import org.openintents.shopping.data.LookupResult +import org.openintents.shopping.data.NewItem /** * Pure-JVM ViewModel tests (no Robolectric): a fake repository + a test @@ -207,15 +210,69 @@ class ShoppingListViewModelTest { val item = vm.state.value.items.single { it.name == "Coffee" } val store = vm.state.value.stores.single() + // The edit dialog loads the item's data first, then edits it. + vm.loadItemEditData(item.itemId) + advanceUntilIdle() vm.setStorePrice(item.itemId, store.id, 350L) advanceUntilIdle() assertEquals(350L, vm.state.value.editingStorePrices[store.id]) + // Reopening the editor reloads the stored value. + vm.endItemEdit() + assertTrue(vm.state.value.editingStorePrices.isEmpty()) vm.loadItemEditData(item.itemId) advanceUntilIdle() assertEquals(350L, vm.state.value.editingStorePrices[store.id]) } + @Test + fun selectList_isRememberedForNextStart() = runTest(dispatcher) { + val repo = FakeShoppingRepository() + val vm = ShoppingListViewModel(repo, dispatcher) + advanceUntilIdle() + vm.createList("Second") + advanceUntilIdle() + val second = vm.state.value.currentListId + val first = vm.state.value.lists.first { it.id != second }.id + + vm.selectList(first) + advanceUntilIdle() + assertEquals(first, repo.getDefaultListId()) + + vm.selectList(second) + advanceUntilIdle() + val restarted = ShoppingListViewModel(repo, dispatcher) + advanceUntilIdle() + assertEquals(second, restarted.state.value.currentListId) + } + + @Test + fun hideChecked_isReadFromAndSavedToSettings() = runTest(dispatcher) { + val settings = FakeSettingsRepository() + settings.setBoolean("hidechecked", true) + val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher, settings = settings) + advanceUntilIdle() + assertTrue(vm.state.value.hideChecked) + + vm.toggleHideChecked() + advanceUntilIdle() + assertFalse(vm.state.value.hideChecked) + assertFalse(settings.getBoolean("hidechecked", true)) + } + + @Test + fun onResume_picksUpChangesMadeElsewhere() = runTest(dispatcher) { + val repo = FakeShoppingRepository() + val vm = ShoppingListViewModel(repo, dispatcher) + advanceUntilIdle() + // e.g. the widget or the legacy UI adds an item while we are paused. + repo.addItem(vm.state.value.currentListId, "Bread") + + vm.onResume() + advanceUntilIdle() + assertTrue(vm.state.value.items.any { it.name == "Bread" }) + } + @Test fun selectStore_appliesStorePricesToItemsAndTotals() = runTest(dispatcher) { val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher) @@ -340,4 +397,111 @@ class ShoppingListViewModelTest { advanceUntilIdle() assertTrue(vm.state.value.items.any { it.name == "OnlyOnA" }) } + + @Test + fun addBarOnTop_isReadFromTheHolosearchSetting() = runTest(dispatcher) { + val settings = FakeSettingsRepository() + val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher, settings = settings) + advanceUntilIdle() + assertFalse(vm.state.value.addBarOnTop) + + settings.setBoolean("holosearch", true) + vm.onResume() + advanceUntilIdle() + assertTrue(vm.state.value.addBarOnTop) + } + + @Test + fun addItemsFromIntent_addsToTheRequestedList() = runTest(dispatcher) { + val repo = FakeShoppingRepository() + val vm = ShoppingListViewModel(repo, dispatcher) + val other = repo.createList("Other") + vm.addItemsFromIntent(other, listOf(NewItem("Tea"), NewItem("Honey"))) + advanceUntilIdle() + assertEquals(other, vm.state.value.currentListId) + assertEquals(setOf("Tea", "Honey"), vm.state.value.items.map { it.name }.toSet()) + } + + @Test + fun selectStore_whileReloading_keepsTheReload() = runTest(dispatcher) { + val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher) + advanceUntilIdle() + vm.addStore("Shop"); advanceUntilIdle() + vm.addItem("Tea"); advanceUntilIdle() + val item = vm.state.value.items.single() + vm.toggle(item) + vm.selectStore(vm.state.value.stores.single().id) // before the toggle's reload finished + advanceUntilIdle() + assertTrue(vm.state.value.items.single().isBought) + } + + @Test + fun scannedBarcode_usesTheLookupAndRemembersTheBarcode() = runTest(dispatcher) { + var lookups = 0 + val repo = FakeShoppingRepository() + val vm = ShoppingListViewModel( + repo, dispatcher, productLookup = { code -> + lookups++ + if (code == "4000417025005") LookupResult.Found("Mineral water") else LookupResult.NotFound + } + ) + advanceUntilIdle() + vm.addScannedBarcode("4000417025005") + advanceUntilIdle() + assertTrue(vm.state.value.items.any { it.name == "Mineral water" }) + assertEquals("Mineral water", vm.state.value.addedFromBarcode) + + // Second scan: the catalogue knows the barcode, no lookup needed. + vm.addScannedBarcode("4000417025005") + advanceUntilIdle() + assertEquals(1, lookups) + } + + @Test + fun unknownBarcode_asksForAName() = runTest(dispatcher) { + val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher, productLookup = { LookupResult.NotFound }) + advanceUntilIdle() + vm.addScannedBarcode("12345670") + advanceUntilIdle() + assertEquals("12345670", vm.state.value.unknownBarcode) + assertFalse(vm.state.value.unknownBarcodeOffline) + + vm.nameUnknownBarcode("Batteries") + advanceUntilIdle() + assertEquals(null, vm.state.value.unknownBarcode) + assertTrue(vm.state.value.items.any { it.name == "Batteries" }) + } + + @Test + fun barcodeLookup_showsProgressAndReportsOffline() = runTest(dispatcher) { + var progressDuringLookup = false + lateinit var vm: ShoppingListViewModel + vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher, productLookup = { + progressDuringLookup = vm.state.value.lookingUpBarcode + LookupResult.Offline + }) + advanceUntilIdle() + vm.addScannedBarcode("12345670") + advanceUntilIdle() + assertTrue(progressDuringLookup) + assertFalse(vm.state.value.lookingUpBarcode) + assertEquals("12345670", vm.state.value.unknownBarcode) + assertTrue(vm.state.value.unknownBarcodeOffline) + + vm.dismissUnknownBarcode() + assertFalse(vm.state.value.unknownBarcodeOffline) + } + + @Test + fun scanButton_canBeHiddenInTheSettings() = runTest(dispatcher) { + val settings = FakeSettingsRepository() + val vm = ShoppingListViewModel(FakeShoppingRepository(), dispatcher, settings = settings) + advanceUntilIdle() + assertTrue(vm.state.value.showScanButton) + + settings.setBoolean("barcode_button", false) + vm.onResume() + advanceUntilIdle() + assertFalse(vm.state.value.showScanButton) + } } diff --git a/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/settings/SettingsViewModelTest.kt b/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/settings/SettingsViewModelTest.kt index 6e75fddc..5521c504 100644 --- a/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/settings/SettingsViewModelTest.kt +++ b/ShoppingList/src/test/java/org/openintents/shopping/ui/compose/settings/SettingsViewModelTest.kt @@ -12,7 +12,7 @@ class SettingsViewModelTest { val vm = SettingsViewModel(FakeSettingsRepository()) // From the catalog defaults (mirroring preferences.xml). assertEquals(false, vm.state.value.bools["hidechecked"]) - assertEquals(true, vm.state.value.bools["loadlastused"]) + assertEquals(true, vm.state.value.bools["showprice"]) assertEquals("2", vm.state.value.choices["fontsize"]) } diff --git a/ShoppingListLibrary/build.xml b/ShoppingListLibrary/build.xml deleted file mode 100644 index 9696cbe3..00000000 --- a/ShoppingListLibrary/build.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/ShoppingListLibrary/src/main/java/org/openintents/shopping/library/util/ShoppingUtils.kt b/ShoppingListLibrary/src/main/java/org/openintents/shopping/library/util/ShoppingUtils.kt index ae975667..47bb58f9 100644 --- a/ShoppingListLibrary/src/main/java/org/openintents/shopping/library/util/ShoppingUtils.kt +++ b/ShoppingListLibrary/src/main/java/org/openintents/shopping/library/util/ShoppingUtils.kt @@ -90,7 +90,7 @@ object ShoppingUtils { arrayOf(itemId.toString()), null) if (existingItems!!.count > 0) { existingItems.moveToFirst() - name = existingItems.getString(0) + name = existingItems.getString(0) ?: "" } existingItems.close() return name diff --git a/ShoppingListWear/build.gradle b/ShoppingListWear/build.gradle deleted file mode 100644 index d19f2482..00000000 --- a/ShoppingListWear/build.gradle +++ /dev/null @@ -1,48 +0,0 @@ -apply plugin: 'com.android.application' - - -android { - compileSdkVersion rootProject.ext.compileSdkVersion - buildToolsVersion rootProject.ext.buildToolsVersion - - defaultConfig { - applicationId "org.openintents.shopping" - minSdkVersion 20 - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - - def propertyFile = new File(projectDir, "build-private.properties") - if (propertyFile.exists()) { - signingConfigs { - release { - Properties props = new Properties() - props.load(new FileInputStream(propertyFile)) - - storeFile file(props.getProperty("key.store")) - storePassword props.getProperty("key.store.password") - keyAlias props.getProperty("key.alias") - keyPassword props.getProperty("key.alias.password") - } - } - buildTypes.release.signingConfig signingConfigs.release - println "Release apks will be signed!" - } else { - println "For signed release apks you need to create a build-private.properties file" - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation project(':ShoppingListLibrary') - implementation "com.google.android.support:wearable:$rootProject.ext.supportWearableVersion" - compileOnly "com.google.android.wearable:wearable:$rootProject.ext.supportWearableVersion" - implementation "com.google.android.gms:play-services-wearable:$rootProject.ext.playServicesVersion" -} diff --git a/ShoppingListWear/src/main/AndroidManifest.xml b/ShoppingListWear/src/main/AndroidManifest.xml deleted file mode 100644 index c77eda7a..00000000 --- a/ShoppingListWear/src/main/AndroidManifest.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/AnimatableTextView.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/AnimatableTextView.java deleted file mode 100644 index b9fee39c..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/AnimatableTextView.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.openintents.shopping.wear; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.TextView; - -public class AnimatableTextView extends TextView { - public AnimatableTextView(Context context) { - super(context); - } - - public AnimatableTextView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public AnimatableTextView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } -} diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/LaunchActivity.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/LaunchActivity.java deleted file mode 100644 index 5c2d54da..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/LaunchActivity.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.openintents.shopping.wear; - -import android.app.Activity; -import android.os.Bundle; - -public class LaunchActivity extends Activity{ - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - ShoppingWearableListenerService.buildShoppingNotification(this, "1"); - finish(); - } -} diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingActivity.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingActivity.java deleted file mode 100644 index 90bd54ca..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingActivity.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.openintents.shopping.wear; - -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.IBinder; -import android.support.v4.content.LocalBroadcastManager; -import android.support.wearable.view.WearableListView; -import android.text.TextUtils; -import android.util.Log; -import android.widget.TextView; - -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.api.GoogleApiClient; -import com.google.android.gms.common.api.ResultCallback; -import com.google.android.gms.wearable.DataItem; -import com.google.android.gms.wearable.DataItemBuffer; -import com.google.android.gms.wearable.DataMap; -import com.google.android.gms.wearable.DataMapItem; -import com.google.android.gms.wearable.PutDataRequest; -import com.google.android.gms.wearable.Wearable; - -import org.openintents.shopping.R; -import org.openintents.shopping.library.provider.ShoppingContract; - -import java.util.ArrayList; -import java.util.List; - -public class ShoppingActivity extends Activity implements ServiceConnection, GoogleApiClient.ConnectionCallbacks, - GoogleApiClient.OnConnectionFailedListener, ResultCallback { - - public static final String EXTRA_LIST_ID = "EXTRA_LIST_ID"; - private static final String TAG = "ShoppintListActivity"; - private TextView mTextView; - private ShoppingWearableListenerService mService; - private com.google.android.gms.common.api.GoogleApiClient mGoogleApiClient; - private ShoppingDataItemAdapter adapter; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_shopping); - final WearableListView listView = (WearableListView) findViewById(R.id.shopping_list); - adapter = new ShoppingDataItemAdapter(this); - listView.setAdapter(adapter); - - listView.setClickListener(new WearableListView.ClickListener() { - @Override - public void onClick(WearableListView.ViewHolder viewHolder) { - long itemId = viewHolder.getItemId(); - Log.d(TAG, "id: " + viewHolder.getItemId()); - - int position = viewHolder.getPosition(); - DataItem item = adapter.getItem(position); - Log.d(TAG, "id: " + DataMapItem.fromDataItem(item).getDataMap().getString(ShoppingContract.ContainsFull.ITEM_NAME)); - Log.d(TAG, "url:" + item.getUri().toString()); - toggleShoppingItem(position, itemId); - } - - @Override - public void onTopEmptyRegionClick() { - - } - }); - - registerLocalNewDataReceiver(); - - mGoogleApiClient = new GoogleApiClient.Builder(this) - .addApi(Wearable.API) - .addConnectionCallbacks(this) - .addOnConnectionFailedListener(this) - .build(); - mGoogleApiClient.connect(); - } - - private void toggleShoppingItem(int position, long itemId) { - PutDataRequest request; - //Wearable.DataApi.putDataItem(mGoogleApiClient, request); - - adapter.remove(position); - } - - private void registerLocalNewDataReceiver() { - IntentFilter intentFilter = new IntentFilter("new_data"); - LocalBroadcastManager.getInstance(this).registerReceiver(new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - byte[] items = intent.getByteArrayExtra("data"); - } - }, intentFilter); - } - - @Override - protected void onResume() { - super.onResume(); - if (mGoogleApiClient.isConnected()){ - loadItems(); - } - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - } - - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - mService = (ShoppingWearableListenerService) service; - } - - @Override - public void onServiceDisconnected(ComponentName name) { - mService = null; - } - - @Override - public void onConnected(Bundle bundle) { - loadItems(); - } - - private void loadItems() { - Wearable.DataApi.getDataItems(mGoogleApiClient).setResultCallback(this); - } - - @Override - public void onConnectionSuspended(int i) { - - } - - @Override - public void onConnectionFailed(ConnectionResult connectionResult) { - - } - - @Override - public void onResult(DataItemBuffer dataItemBuffer) { - List items = new ArrayList(); - String listPrefix = "/" + getListId() + "/"; - - for (int i=0; i< dataItemBuffer.getCount(); i++){ - DataItem item = dataItemBuffer.get(i); - if (item.getUri().getPath().startsWith(listPrefix)){ - DataMap mapItem = DataMapItem.fromDataItem(item).getDataMap(); - String name = mapItem.getString(ShoppingContract.ContainsFull.ITEM_NAME); - if (!TextUtils.isEmpty(name)) { - items.add(item); - } - } - } - - adapter.setItems(items); - } - - private String getListId() { - return getIntent().getStringExtra(EXTRA_LIST_ID); - } -} diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingDataItemAdapter.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingDataItemAdapter.java deleted file mode 100644 index d4a95030..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingDataItemAdapter.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.openintents.shopping.wear; - -import android.content.Context; -import android.support.wearable.view.WearableListView; -import android.util.Log; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.google.android.gms.wearable.DataItem; -import com.google.android.gms.wearable.DataItemBuffer; -import com.google.android.gms.wearable.DataMap; -import com.google.android.gms.wearable.DataMapItem; - -import org.openintents.shopping.R; -import org.openintents.shopping.library.provider.ShoppingContract; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - - -public class ShoppingDataItemAdapter extends WearableListView.Adapter { - - private static final String EMPTY_STRING = ""; - private static final String TAG = "ShoppingDataItemAdapter"; - private List mItems = new ArrayList(); - private Context mContext; - - public ShoppingDataItemAdapter(Context context){ - mContext = context; - } - - @Override - public WearableListView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int position) { - return new WearableListView.ViewHolder(new ShoppingItemView(mContext, 14, 20)); - } - - @Override - public void onBindViewHolder(WearableListView.ViewHolder viewHolder, int position) { - String name = DataMapItem.fromDataItem(mItems.get(position)).getDataMap().getString(ShoppingContract.ContainsFull.ITEM_NAME); - String quantity =DataMapItem.fromDataItem(mItems.get(position)).getDataMap().getString(ShoppingContract.ContainsFull.QUANTITY); - String units = DataMapItem.fromDataItem(mItems.get(position)).getDataMap().getString(ShoppingContract.ContainsFull.ITEM_UNITS); - String status = DataMapItem.fromDataItem(mItems.get(position)).getDataMap().getString(ShoppingContract.ContainsFull.STATUS); - String titleDisplay = getTitle(name, quantity, units); - ((TextView)viewHolder.itemView.findViewById(R.id.title)).setText(titleDisplay); - - String tags = DataMapItem.fromDataItem(mItems.get(position)).getDataMap().getString(ShoppingContract.ContainsFull.ITEM_TAGS); - if (tags == null){ - tags = EMPTY_STRING; - } - ((TextView)viewHolder.itemView.findViewById(R.id.tags)).setText(tags); - } - - private String getTitle(String name, String quantity, String units) { - String titleDisplay; - if (quantity == null){ - titleDisplay = name; - } else { - if (units == null){ - titleDisplay = quantity + " " + name; - } else { - titleDisplay = quantity + units + " " + name; - } - } - return titleDisplay; - } - - public void setItems(DataItemBuffer items) { - mItems.clear(); - for (int i= 0; i < items.getCount();i++){ - Log.d(TAG, "received: " + items.get(i).getUri() ); - - DataMap contentValues = DataMapItem.fromDataItem(items.get(i)).getDataMap(); - Log.d(TAG, "content:" + contentValues.getString(ShoppingContract.ContainsFull.ITEM_NAME)); - this.mItems.add(items.get(i)); - } - notifyDataSetChanged(); - } - - public void setItems(Collection items) { - mItems.clear(); - mItems.addAll(items); - notifyDataSetChanged(); - } - - public DataItem getItem(int position){ - if (mItems != null){ - return mItems.get(position); - } else { - return null; - } - } - - @Override - public int getItemCount() { - if (mItems == null){ - return 0; - } else { - return mItems.size(); - } - } - - public void remove(int position) { - mItems.remove(position); - notifyDataSetChanged(); - } -} diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingItemView.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingItemView.java deleted file mode 100644 index cb64cec0..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingItemView.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.openintents.shopping.wear; - -import android.content.Context; -import android.support.wearable.view.WearableListView; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.TextView; - -import org.openintents.shopping.R; - -public class ShoppingItemView extends FrameLayout implements WearableListView.OnCenterProximityListener { - - - private final TextView title; - private final TextView tags; - private final float mDefaultTextSize; - private final float mSelectedTextSize; - - public ShoppingItemView(Context context, float defaultTextSize, float selectedTextSize) { - super(context); - View.inflate(context, R.layout.item_shopping, this); - title = (TextView) findViewById(R.id.title); - tags = (TextView) findViewById(R.id.tags); - mDefaultTextSize = defaultTextSize; - mSelectedTextSize = selectedTextSize; - } - - @Override - public void onCenterPosition(boolean b) { - title.animate().scaleX(1f).scaleY(1f).alpha(1); - tags.animate().scaleX(1f).scaleY(1f).alpha(1); - } - - @Override - public void onNonCenterPosition(boolean b) { - title.animate().scaleX(0.8f).scaleY(0.8f).alpha(0.6f); - tags.animate().scaleX(0.8f).scaleY(0.8f).alpha(0.6f); - } -} diff --git a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingWearableListenerService.java b/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingWearableListenerService.java deleted file mode 100644 index 9c7f2d8c..00000000 --- a/ShoppingListWear/src/main/java/org/openintents/shopping/wear/ShoppingWearableListenerService.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.openintents.shopping.wear; - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.support.v4.app.NotificationCompat; -import android.support.v4.app.NotificationManagerCompat; - -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.api.GoogleApiClient; -import com.google.android.gms.common.data.FreezableUtils; -import com.google.android.gms.wearable.DataEvent; -import com.google.android.gms.wearable.DataEventBuffer; -import com.google.android.gms.wearable.DataMap; -import com.google.android.gms.wearable.DataMapItem; -import com.google.android.gms.wearable.MessageEvent; -import com.google.android.gms.wearable.Node; -import com.google.android.gms.wearable.Wearable; -import com.google.android.gms.wearable.WearableListenerService; - -import org.openintents.shopping.R; - -public class ShoppingWearableListenerService extends WearableListenerService implements GoogleApiClient.ConnectionCallbacks, - GoogleApiClient.OnConnectionFailedListener { - private GoogleApiClient mGoogleApiClient; - - @Override - public void onCreate() { - super.onCreate(); - mGoogleApiClient = new GoogleApiClient.Builder(this) - .addApi(Wearable.API) - .addConnectionCallbacks(this) - .addOnConnectionFailedListener(this) - .build(); - mGoogleApiClient.connect(); - } - - @Override - public void onPeerConnected(Node peer) { - super.onPeerConnected(peer); - } - - @Override - public void onDataChanged(DataEventBuffer dataEvents) { - FreezableUtils.freezeIterable(dataEvents); - for (DataEvent dataEvent : dataEvents) { - if (dataEvent.getType() == DataEvent.TYPE_CHANGED) { - DataMap dataMap = DataMapItem.fromDataItem(dataEvent.getDataItem()).getDataMap(); - - } - } - } - - @Override - public void onMessageReceived(MessageEvent messageEvent) { - if (messageEvent.getPath() != null){ - String listId = new String(messageEvent.getData()); - buildShoppingNotification(this, listId); - } - } - - public static void buildShoppingNotification(Context context, String listId) { - Intent shoppingActivityIntent = createShoppingActivityIntent(context, listId); - PendingIntent intent = PendingIntent.getActivity(context, 0, shoppingActivityIntent, 0); - Notification notification = new NotificationCompat.Builder(context) - .setContentText("Start Shopping") - .setContentTitle("Items are synchronized") - .setSmallIcon(R.drawable.ic_launcher_shoppinglist) - .setContentIntent(intent) - .build(); - NotificationManagerCompat.from(context).notify(1, notification); - } - - private static Intent createShoppingActivityIntent(Context context, String listId) { - Intent i = new Intent(context, ShoppingActivity.class); - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - i.putExtra(ShoppingActivity.EXTRA_LIST_ID, listId); - return i; - } - - @Override - public void onConnected(Bundle bundle) { - - } - - @Override - public void onConnectionSuspended(int i) { - - } - - @Override - public void onConnectionFailed(ConnectionResult connectionResult) { - - } -} diff --git a/ShoppingListWear/src/main/res/drawable-xhdpi/ic_launcher_shoppinglist.png b/ShoppingListWear/src/main/res/drawable-xhdpi/ic_launcher_shoppinglist.png deleted file mode 100644 index 9ff7d265..00000000 Binary files a/ShoppingListWear/src/main/res/drawable-xhdpi/ic_launcher_shoppinglist.png and /dev/null differ diff --git a/ShoppingListWear/src/main/res/layout/activity_shopping.xml b/ShoppingListWear/src/main/res/layout/activity_shopping.xml deleted file mode 100644 index 74ab9aba..00000000 --- a/ShoppingListWear/src/main/res/layout/activity_shopping.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ShoppingListWear/src/main/res/layout/item_shopping.xml b/ShoppingListWear/src/main/res/layout/item_shopping.xml deleted file mode 100644 index d1d004aa..00000000 --- a/ShoppingListWear/src/main/res/layout/item_shopping.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/ShoppingListWear/src/main/res/values/strings.xml b/ShoppingListWear/src/main/res/values/strings.xml deleted file mode 100644 index 732a54c1..00000000 --- a/ShoppingListWear/src/main/res/values/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - Shopping - Hello Round World! - Hello Square World! - - diff --git a/UPGRADE.md b/UPGRADE.md index 9abdcb3b..311a2422 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,57 @@ Written 2026-06-09 against commit `81bc3c2` (versionName 2.2.1, versionCode 1002 --- +## 0a. RELEASE 2.3.0 PREP (2026-09-24) + +versionName 2.3.0 / versionCode 100230. Release notes: `res/raw/recent_changes.txt`, +`src/play/play/en-US/whatsnew`, `fastlane/.../changelogs/100230.txt` (F-Droid). + +Done in this pass (source audit, all components): +- Compose UI: stale-refresh race, last-used list persisted/validated, catalogue item + reuse on add (no duplicates), refresh on resume, CSV import policy KEEP, price + validation, rotation-safe dialogs/input, settings honored (hidechecked, showprice, + capitalization), all strings translatable (`values/strings_compose.xml`), a11y labels, + list themes shared with the legacy UI (stored as "1"/"2"/"3"). +- CSV: persistable SAF grants, crash fixes (HandyShopper import/export, non-list URIs), + export truncates ("wt"). +- Provider/DB: SQL fixes for `containsfull/#` and `itemstores/#`, no selection-arg SQL + concatenation, cursor leaks, WAL off so the backup agent captures all data. +- Legacy UI: move/delete by id (rotation-safe), cursor-reload crashes, first-run layout + chooser, edge-to-edge opt-out for all full-screen legacy activities (values-v35). +- Widget receiver no longer exported; per-widget settings PendingIntents. +- Removed dead ShoppingListWear/, src/playInternet/, Ant build.xml, Travis CI, + aTrackDog meta-data. CI is now `.github/workflows/build.yml` (unit tests, tstore APK, + play AAB, lint). + +Feature parity with the legacy UI (checked before release 2.3.0). The legacy screens +are no longer in the manifest; their sources (ui/ShoppingActivity.java, ui/widget/ +ShoppingItemsView.kt, ui/dialog/*, share/*, theme/*, provider/Alert|Location|Hardware|Tag, +PickItemsActivity, AddLocationAlertActivity, ...) can be deleted: + +| Legacy feature | New UI | +|---|---| +| Add / check / edit / remove items, undo | yes (tap = check, long-press = edit) | +| Search / add in the action bar | yes, setting "Search/add items in action bar" | +| 10 sort orders, per-list sort, Pick items sort, list order | yes (same settings) | +| Store filter / tag filter ("use_filters") | yes, chips above the list | +| Mark all / unmark all / clean up with undo, shake to clean up | yes | +| Move / copy / delete item permanently | yes (edit dialog) | +| Per-item stores (stocks, aisle, price) | yes (edit dialog -> Stores..., legacy screen) | +| Priority subtotal, show price/tags/units/quantity/priority | yes | +| Themes incl. "use for all lists", font size | yes | +| Keep screen on, orientation, reset quantity, completion scope | yes | +| Convert CSV (HandyShopper, encoding, policy), About | yes (menu) | +| Items from other apps (SEND text, INSERT_FROM_EXTRAS), list shortcuts, widget | yes | +| Other apps picking items (PICK/GET_CONTENT item) | removed with the legacy screen (2.3.0) | +| Quick edit mode (tap quantity/priority) | replaced by long-press edit | +| Fast scroll | yes ("Fast scrolling" setting) | +| - | new: compact view, barcode scanning (scanner app + Open Food Facts) | +| Location alerts (OI Locations), GTalk sharing, Wear sync, theme packs from other apps, Market add-on links | dropped: the partner apps/services no longer exist | + +Release channels: F-Droid (tag + `.fdroid.yml`), GitHub releases for Obtainium and +Zapstore (`.github/workflows/release.yml`, needs the signing secrets and +`ZAPSTORE_SIGN_WITH`), Google Play (`bundlePlayRelease`, uploaded by hand). + ## 0b. ARCHITECTURE DIRECTION (updated 2026-06-09) After the Google-Play upgrade + Kotlin migration, the project pivoted to: diff --git a/ci/wait_for_emulator b/ci/wait_for_emulator deleted file mode 100644 index 31788387..00000000 --- a/ci/wait_for_emulator +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -bootanim="" -failcounter=0 -until [[ "$bootanim" =~ "stopped" ]]; do - bootanim=`adb -e shell getprop init.svc.bootanim 2>&1` - echo "$bootanim" - if [[ "$bootanim" =~ "not found" ]]; then - let "failcounter += 1" - if [[ $failcounter -gt 3 ]]; then - echo "Failed to start emulator" - exit 1 - fi - fi - sleep 1 -done -echo "Done" diff --git a/docs/NOSTR_SHARING.md b/docs/NOSTR_SHARING.md new file mode 100644 index 00000000..a57575be --- /dev/null +++ b/docs/NOSTR_SHARING.md @@ -0,0 +1,225 @@ +# Shared shopping lists over Nostr: plan + +Goal: several people keep **one shopping list** in sync, end-to-end encrypted, +over Nostr relays. Default relay `wss://shopping.openintents.org`; users can +bring their own. Sharing needs no sign-up: every user gets a Nostr account +automatically, and can switch to their own existing account. A list is shared by +showing a QR code, and others join by scanning it. A shared list behaves like a +small private group chat whose messages are list updates. + +Version 1 limits: a device can share or join **one list at a time**. The data +model allows more lists later. + +**Decisions made** +- The app declares the `INTERNET` permission. Sharing is opt-in: nothing + connects until the user shares or joins a list. +- Every user has a **personal Nostr account**, created automatically. Users can + switch to their **own account** (nsec or an external signer app) at any time, + including right before sharing a list. + +--- + +## 1. User experience + +**Account (automatic)** +- The first time the user shares or joins a list, the app silently creates a + Nostr key pair: the *automatic account*. There's no dialog and nothing to write + down. +- Settings → *Nostr account* shows the account (npub, a display name used for + "changed by …") and offers: + - *Use my own account…*: paste an `nsec`, or connect a signer app (NIP-55, + for example Amber), which keeps the key outside this app. + - *Back up key*: shows or copies the `nsec` of the automatic account. + - *Switch back to the automatic account*. +- Sharing a list offers the same choice inline: *Share as: Automatic account ▾ / + Use my own account…* + +**Share (owner)** +1. List menu → *Share list…* +2. The first time only, a short explainer: "Anyone who scans this code can see + and change this list. Updates go end-to-end encrypted through + shopping.openintents.org." The *Relay* link opens the relay setting, and + *Share as* chooses the account. +3. A QR code appears, with *Copy link* and *Send link* (Android share sheet). + The list is live from this moment. + +**Join (member)** +- Scan the QR code with the phone's camera app. It opens a link that goes + straight into the app (see section 5). Alternatively, open a shared link, or tap + *Join shared list* in the drawer. +- A confirmation shows the list name and who shared it, for example *Join + "Groceries" from Anna?* → the list appears and fills in within a second or + two. The joiner's automatic account is created here if needed. + +**While shared** +- A small cloud icon in the app bar shows the state: synced, syncing, offline + (changes are queued) or error. +- Items show who changed them last, when that isn't you ("checked by Anna"). +- *Share list…* shows the same QR again, so more people can join. +- *Stop sharing* keeps the local copy and stops syncing. It cannot un-share + data others already have. + +For the default user there are still no keys, no relay URLs and no sign-up. + +## 2. Protocol (v1) + +Two kinds of keys: + +- **User key (identity):** the automatic or own account. It **signs** the events + a user publishes, so every change is attributed to an author. +- **List secret (group key):** a random 32 bytes `S` per shared list. It + **encrypts** list content and addresses events. Everyone who scanned the QR + code has it. + +### Derived from the list secret + +| Derived | How | Purpose | +|---|---|---| +| List key pair | `lsk = S` (secp256k1, retried if out of range), `lpk = xonly(lsk·G)` | Only used to derive the conversation key | +| Conversation key | NIP-44 v2 `get_conversation_key(lsk, lpk)` | Encrypts all content (NIP-44 v2). The MAC makes content from anyone without `S` fail to decrypt, so it gets ignored. | +| List tag | `h = hex(HMAC-SHA256(S, "list"))` | Relay filter for this list, opaque to the relay | +| Item address | `d = hex(HMAC-SHA256(S, "item:" + itemUuid))` | Item ids the relay cannot link to names | + +### Events +- **Item** = one addressable (parameterized replaceable) event, kind `3xxxx`. + Pick an unused number and register it in nostr-protocol/nips. + - Signed by the **user key**; `tags`: `["d", d]`, `["h", h]`. + - `content` = NIP-44 encryption, with the list conversation key, of JSON such as + `{"v":1,"id":"","name":"Milk","qty":"2","units":"l","price":119,"status":1,"prio":null,"tags":null,"note":null,"hlc":""}`. + - The relay keeps the latest version per (author, item). Reading the whole + list is `REQ {"kinds":[K], "#h":[h]}`. The client keeps, per item, the + version with the newest `hlc` across all authors. +- **List meta** (name, theme, the sharer's display name): the same kind, with + `d = HMAC(S, "meta")`. +- **Delete**: publish a tombstone `{"v":1,"id":…,"deleted":true,"hlc":…}` with a + NIP-40 `expiration` tag of about 60 days, so relays eventually drop it. +- **Display names:** the npub of the event author, resolved to a name via the + author's kind-0 profile if it has one. Otherwise use the nickname they set in the + app, which is sent encrypted inside the list meta and member events. + +### Merge rules +- Newest wins per item, by `hlc` (sub-second ordering), with the author pubkey and + then the event id as tie-breakers. +- Checking an item off is simply a status change on the item event, which + gives offline-safe "last change wins" semantics. +- Local edits go into an **outbox** table and are published when online; an + item is marked synced when the relay replies with `OK`. +- Only events that decrypt with the list conversation key count. Signatures are + verified as usual. + +### The share link and QR code +``` +https://shopping.openintents.org/j#v=1&s=&r=wss%3A%2F%2Fshopping.openintents.org&n=Groceries&o= +``` +- The secret sits in the **fragment**, which browsers never send to the + server, so even the landing page never sees it. +- `r` can be repeated for several relays (bring your own relay). The owner's + relays travel in the QR code so everyone uses the same ones. +- `o` (optional) shows the sharer's account in the join dialog. +- As a QR code this is about 190 characters, which scans easily. + +## 3. Relays + +- **Default: `shopping.openintents.org`**, running strfry or nostr-rs-relay: + - A write policy that only accepts kind `K` (plus kind 0 profiles), content + under 4 KB, and rate limits per IP and per pubkey. + - A retention policy, for example dropping events untouched for 12 months. + - A NIP-11 info document. + - The same host serves `/.well-known/assetlinks.json` (for the Android App + Link) and a tiny `/j` landing page ("Install OI Shopping List"). +- **Bring your own:** Settings → *Relays* lets users edit the list, which + defaults to `wss://shopping.openintents.org`. The setting is used when a new + share is created. Joining always uses the relays in the QR code. Users with + their own account can import that account's relay list (NIP-65). +- The client connects to all relays of the list, publishes to all of them, and + merges what it reads (deduplicated by event id). + +## 4. App architecture + +| Piece | Where | Notes | +|---|---|---| +| Crypto: secp256k1 BIP-340 sign/verify, ECDH, NIP-44 v2 (HKDF, ChaCha20, HMAC-SHA256), bech32 (npub/nsec, NIP-19) | new `nostr/crypto` package, pure Kotlin | Small and no JNI, so no per-ABI native libraries. Verified against the official BIP-340, NIP-44 and NIP-19 test vectors. Android's own ChaCha20 needs API 28, and minSdk is 21, so it is bundled. | +| Account | `nostr/Account` | `Signer` interface with two implementations: `LocalKeySigner` (the automatic account or a pasted nsec, stored encrypted with an Android Keystore key and excluded from backups) and `Nip55Signer` (external signer app such as Amber, via intents/content resolver). | +| Nostr client (NIP-01 `REQ` / `EVENT` / `EOSE` / `OK` / `CLOSE`) | `nostr/RelayClient` | WebSocket via OkHttp, about 300–400 KB after R8. Reconnects with backoff and only runs while the app is visible. | +| Sync engine | `sync/ListSync` | Maps provider rows to item events and back, owns the outbox and HLC, and applies the merge rules. Signs through `Signer`, so both account kinds work. Pure logic with a fake relay, so it is JVM-testable. | +| Data | DB migration | Adds `contains.remote_id` (UUID), `modified_hlc` and `modified_by`, plus a `shared_lists(list_id, secret, relays, joined_at)` table and an `outbox` table. The secret is stored encrypted with the Keystore key. | +| QR code | show: `zxing-core` encoder (R8 keeps only the encoder); scan: the system camera plus an App Link | No camera code or ML Kit, so the app stays FOSS/F-Droid friendly. In-app *Scan* hands off to an installed scanner (for example Binary Eye), with *Paste link* as a fallback. | +| UI | Compose: share dialog with QR, join confirmation, sync badge, Nostr account and relay settings | | + +**Sync triggers** +- Connect and subscribe when the app comes to the foreground; disconnect about + 30 seconds after it goes to the background. +- Changes from the widget or other apps are picked up on the next foreground, + or immediately through the provider's change notification while connected. +- Background sync (JobScheduler, with no extra library) is optional for v1.1. + +## 5. Permissions, links and size + +- **`INTERNET`** is added to the manifest. The store descriptions change from + "does not require the internet permission" to "only goes online when you + share a list". +- App Links need `autoVerify` on the `/j` path plus `assetlinks.json` with the + Play and F-Droid signing certificates, which differ, so list both. +- NIP-55 signers need a `` entry for the `nostrsigner:` scheme + (Android 11+ package visibility). +- Estimated APK growth: OkHttp about 350 KB, zxing encoder about 60 KB, crypto + about 50 KB. That is around 0.45 MB on top of 3.6 MB. + +## 6. Security and privacy properties (to state in the UI and the docs) + +- Relays see: which pubkeys write to a list tag, how many items it has, when they + change, and IP addresses. They never see item names, prices or list names. + - **Automatic account:** the pubkey is a fresh, unlinked identity. + - **Own account:** the relay (and anyone reading it) can see that this public + identity takes part in *some* shared list. The UI mentions this when the user + picks their own account. +- **Whoever has the QR code or link can read and write the list until it is + re-keyed.** Treat it like a password. +- No forward secrecy, and removed members can't be locked out in v1. The + workaround is *Stop sharing* followed by *Share list…* again, which creates a + new secret and QR code. +- Events are authenticated twice: by a valid signature of the author, and by + NIP-44 decryption with the list key. Strangers cannot inject changes. +- The automatic account's key never leaves the device unless the user backs it + up. Losing it only loses the name on past changes: rejoining a list just needs + the QR code again. + +## 7. Delivery phases + +1. **Spec and crypto core.** Write the protocol doc with the kind number. Add + BIP-340, NIP-44 and NIP-19 in pure Kotlin with the official test vectors as + JVM tests. +2. **Accounts.** The automatic account, nsec import and export, the NIP-55 + signer, Keystore storage, and the settings screen. +3. **Sync engine and data.** DB migration, outbox, HLC, merge. Test two or three + simulated users against an in-memory relay: offline edits, conflicts, + deletions, joining late, one user with their own account. +4. **Relay client.** OkHttp WebSocket plus reconnect. Add an integration test in + CI against a throwaway strfry container. +5. **UI.** Share dialog with QR code and *Share as*, join by link or App Link, + sync badge, "changed by", relay settings, stop sharing. Localizable strings. +6. **Infrastructure.** Deploy `shopping.openintents.org` (relay with write + policy, `assetlinks.json`, `/j` landing page) and add monitoring. +7. **Beta.** Test on two real phones: list on one, check off on the other, + offline then back online, automatic account plus own account, then release. + +## 8. Later (explicitly out of scope for v1) + +- Several shared lists per device (the model already supports it; the UI limits it to one). +- Inviting by npub instead of a QR code: send the list link as a NIP-17 + private message. This becomes possible now that every user has an account. +- Member management and revocation: re-key and deliver the new secret only to + the remaining members' npubs via NIP-17, or use NIP-EE/MLS groups. +- NIP-46 remote signers (bunkers). +- Background push via a relay-to-UnifiedPush bridge. +- A web client using the same protocol. + +## 9. Open questions for the maintainer + +1. Who runs `shopping.openintents.org`, and what retention period should it use? +2. Are the Play and F-Droid signing certificates available for `assetlinks.json`? +3. Should the automatic account publish a kind-0 profile with the nickname? That's + more convenient but more public. The default proposed here is no: the nickname + stays inside the encrypted list. +4. Should there be interoperability with an existing Nostr list or tasks app, + which would mean reusing its event format instead of a new kind? diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 00000000..d3cbdee1 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +تتبع عناصر قائمة تسوقك مع قائمة تسوق OI. diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 100644 index 00000000..398b3cf3 --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +قائمة التسوق OI diff --git a/fastlane/metadata/android/be/short_description.txt b/fastlane/metadata/android/be/short_description.txt new file mode 100644 index 00000000..e6ba0404 --- /dev/null +++ b/fastlane/metadata/android/be/short_description.txt @@ -0,0 +1 @@ +Сачыце за таварамі пры дапамозе OI Пакупкі. diff --git a/fastlane/metadata/android/be/title.txt b/fastlane/metadata/android/be/title.txt new file mode 100644 index 00000000..4d57cddd --- /dev/null +++ b/fastlane/metadata/android/be/title.txt @@ -0,0 +1 @@ +OI Пакупкі diff --git a/fastlane/metadata/android/bg/short_description.txt b/fastlane/metadata/android/bg/short_description.txt new file mode 100644 index 00000000..8afd1383 --- /dev/null +++ b/fastlane/metadata/android/bg/short_description.txt @@ -0,0 +1 @@ +Следете списъка си с артикули за пазаруване с OI Списък за Пазаруване. diff --git a/fastlane/metadata/android/bg/title.txt b/fastlane/metadata/android/bg/title.txt new file mode 100644 index 00000000..6820100d --- /dev/null +++ b/fastlane/metadata/android/bg/title.txt @@ -0,0 +1 @@ +OI Списък за Пазаруване diff --git a/fastlane/metadata/android/ca/short_description.txt b/fastlane/metadata/android/ca/short_description.txt new file mode 100644 index 00000000..9cf130d9 --- /dev/null +++ b/fastlane/metadata/android/ca/short_description.txt @@ -0,0 +1 @@ +Porta un registre dels teus elements de compra amb la llista de compra d'OI. diff --git a/fastlane/metadata/android/ca/title.txt b/fastlane/metadata/android/ca/title.txt new file mode 100644 index 00000000..de698459 --- /dev/null +++ b/fastlane/metadata/android/ca/title.txt @@ -0,0 +1 @@ +Llista de compra d'OI diff --git a/fastlane/metadata/android/cs/short_description.txt b/fastlane/metadata/android/cs/short_description.txt new file mode 100644 index 00000000..2410ab77 --- /dev/null +++ b/fastlane/metadata/android/cs/short_description.txt @@ -0,0 +1 @@ +Sledujte své nákupní položky s OI Nákupní seznam. diff --git a/fastlane/metadata/android/cs/title.txt b/fastlane/metadata/android/cs/title.txt new file mode 100644 index 00000000..491d4fa3 --- /dev/null +++ b/fastlane/metadata/android/cs/title.txt @@ -0,0 +1 @@ +OI Nákupní seznam diff --git a/fastlane/metadata/android/de/changelogs/100230.txt b/fastlane/metadata/android/de/changelogs/100230.txt new file mode 100644 index 00000000..a69487ca --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/100230.txt @@ -0,0 +1,7 @@ +- Neuer Hauptbildschirm: Tippen hakt ab, langes Drücken bearbeitet. Alle Themen bleiben. +- Artikel per Barcode-Scan hinzufügen (Namen von Open Food Facts). +- Kompakte Ansicht, Schnell-Scrollen, Suchen/Hinzufügen oben. +- Für Android 15 aktualisiert; CSV über die Dateiauswahl. +- Abstürze (CSV, Widget, Automatisierung, Übersetzungen) behoben. +- Sicherungen enthalten die neuesten Änderungen. +- Kleinere App. diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt new file mode 100644 index 00000000..9c99e457 --- /dev/null +++ b/fastlane/metadata/android/de/full_description.txt @@ -0,0 +1,21 @@ +

Mit der OI Einkaufsliste erstellen und verwalten Sie Einkaufslisten und andere Checklisten. Fügen Sie Artikel zur Liste hinzu, haken Sie sie im Geschäft ab und räumen Sie die Liste danach auf.

+ +

Kostenlos, quelloffen und werbefrei seit 2009. Ihre Listen bleiben auf Ihrem Gerät. Die App geht nur online, um die Namen gescannter Barcodes bei Open Food Facts nachzuschlagen – das lässt sich abschalten.

+ +

Funktionen

+
    +
  • Mehrere Listen, z. B. Lebensmittel, Baumarkt, Wunschliste
  • +
  • Artikel schnell hinzufügen, mit Vorschlägen aus bereits verwendeten Artikeln oder per Barcode-Scan (Produktnamen von Open Food Facts)
  • +
  • Artikel abhaken mit Rückgängig-Funktion; gekaufte Artikel mit einem Tipp entfernen
  • +
  • Auswahlmodus: früher gekaufte Artikel wieder hinzufügen
  • +
  • Menge, Einheit, Priorität, Tags und Notizen für jeden Artikel
  • +
  • Preise pro Geschäft, mit Summen für die Liste
  • +
  • Viele Sortierungen, Filter nach Geschäft und Tag, erledigte Artikel ausblenden, kompakte Ansicht, Schnell-Scrollen, Schriftgröße und Themen
  • +
  • Widget für den Startbildschirm
  • +
  • Listen als CSV importieren und exportieren (OI Shopping List und HandyShopper)
  • +
  • Liste als Text teilen oder Artikel aus anderen Apps übernehmen
  • +
  • Automatisierung (z. B. Tasker) zum Aufräumen von Listen
  • +
  • In etwa 40 Sprachen verfügbar
  • +
+ +

Quellcode und Fehlermeldungen: https://github.com/openintents/shoppinglist

diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt new file mode 100644 index 00000000..3fbb29f4 --- /dev/null +++ b/fastlane/metadata/android/de/short_description.txt @@ -0,0 +1 @@ +Einfache, werbefreie Einkaufsliste: Artikel abhaken, Preise pro Geschäft diff --git a/fastlane/metadata/android/de/title.txt b/fastlane/metadata/android/de/title.txt new file mode 100644 index 00000000..d53c4541 --- /dev/null +++ b/fastlane/metadata/android/de/title.txt @@ -0,0 +1 @@ +OI Einkaufsliste diff --git a/fastlane/metadata/android/el/short_description.txt b/fastlane/metadata/android/el/short_description.txt new file mode 100644 index 00000000..2062f5c3 --- /dev/null +++ b/fastlane/metadata/android/el/short_description.txt @@ -0,0 +1 @@ +Παρακολουθήστε τα αντικείμενα αγορών σας με την OI Λίστα Αγορών. diff --git a/fastlane/metadata/android/el/title.txt b/fastlane/metadata/android/el/title.txt new file mode 100644 index 00000000..aecdbbf8 --- /dev/null +++ b/fastlane/metadata/android/el/title.txt @@ -0,0 +1 @@ +OI Λίστα Αγορών diff --git a/fastlane/metadata/android/en-US/changelogs/100230.txt b/fastlane/metadata/android/en-US/changelogs/100230.txt new file mode 100644 index 00000000..d369b1d3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/100230.txt @@ -0,0 +1,7 @@ +- New main screen: tap to check, long-press to edit. All list themes kept. +- Add items by scanning barcodes (names from Open Food Facts). +- Compact view, fast scroll, search/add in the top bar. +- Updated for Android 15; CSV via the system file picker. +- Fixed crashes (CSV, widget, automation, translations) and wrong item moved after rotating. +- Backups include the latest changes. +- Smaller app. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 00000000..de21b61d --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,21 @@ +

OI Shopping List makes it easy to create and manage shopping lists and other checklists. Add items to a list, check them off in the shop, and clean up the list when you are done.

+ +

Free, open source and ad-free since 2009. Your lists stay on your device. The app only goes online to look up the names of scanned barcodes on Open Food Facts, which you can turn off.

+ +

Features

+
    +
  • Several lists, e.g. groceries, hardware store, wish list
  • +
  • Add items quickly, with suggestions from items you used before, or by scanning a barcode (product names from Open Food Facts)
  • +
  • Check items off with undo; clean up bought items with one tap
  • +
  • Pick mode: re-add items you bought before
  • +
  • Quantity, units, priority, tags and notes for each item
  • +
  • Prices per store, with totals for the list
  • +
  • Many sort orders, store and tag filters, hide checked items, compact view, fast scroll, font size and themes
  • +
  • Home screen widget
  • +
  • Import and export lists as CSV (OI Shopping List and HandyShopper formats)
  • +
  • Share a list as text, or add items shared from other apps
  • +
  • Automation (e.g. Tasker) to clean up lists
  • +
  • Available in about 40 languages
  • +
+ +

Source code and issue tracker: https://github.com/openintents/shoppinglist

diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 00000000..e76958ac Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 00000000..96105772 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 00000000..c6ff0d5d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 00000000..7fab6809 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 00000000..aad686f5 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 00000000..4084fd46 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 00000000..853b16bc --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Simple, ad-free shopping list: add items, check them off, track prices per store diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 00000000..61937c29 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +OI Shopping List diff --git a/fastlane/metadata/android/es/short_description.txt b/fastlane/metadata/android/es/short_description.txt new file mode 100644 index 00000000..98ee12d8 --- /dev/null +++ b/fastlane/metadata/android/es/short_description.txt @@ -0,0 +1 @@ +Controle sus compras con OI Shopping List diff --git a/fastlane/metadata/android/fa/short_description.txt b/fastlane/metadata/android/fa/short_description.txt new file mode 100644 index 00000000..7a06617d --- /dev/null +++ b/fastlane/metadata/android/fa/short_description.txt @@ -0,0 +1 @@ +پیگیری از اقلام خرید خود را با لیست خرید OI. diff --git a/fastlane/metadata/android/fa/title.txt b/fastlane/metadata/android/fa/title.txt new file mode 100644 index 00000000..553d9310 --- /dev/null +++ b/fastlane/metadata/android/fa/title.txt @@ -0,0 +1 @@ +OI لیست خرید diff --git a/fastlane/metadata/android/fi/short_description.txt b/fastlane/metadata/android/fi/short_description.txt new file mode 100644 index 00000000..91bde90c --- /dev/null +++ b/fastlane/metadata/android/fi/short_description.txt @@ -0,0 +1 @@ +Pidä kirjaa sinun ostoksistasi OI Ostoslistan avulla. diff --git a/fastlane/metadata/android/fi/title.txt b/fastlane/metadata/android/fi/title.txt new file mode 100644 index 00000000..96f5281d --- /dev/null +++ b/fastlane/metadata/android/fi/title.txt @@ -0,0 +1 @@ +OI Ostoslista diff --git a/fastlane/metadata/android/fr/short_description.txt b/fastlane/metadata/android/fr/short_description.txt new file mode 100644 index 00000000..1561812e --- /dev/null +++ b/fastlane/metadata/android/fr/short_description.txt @@ -0,0 +1 @@ +Garder une trace de vos courses avec OI Shopping List. diff --git a/fastlane/metadata/android/hi/short_description.txt b/fastlane/metadata/android/hi/short_description.txt new file mode 100644 index 00000000..904dd346 --- /dev/null +++ b/fastlane/metadata/android/hi/short_description.txt @@ -0,0 +1 @@ +अपने खरीदारी के सामान का ट्रेक रखें OI शौपिंग लिस्ट के साथ. diff --git a/fastlane/metadata/android/hi/title.txt b/fastlane/metadata/android/hi/title.txt new file mode 100644 index 00000000..8bf55e68 --- /dev/null +++ b/fastlane/metadata/android/hi/title.txt @@ -0,0 +1 @@ +OI खरीदारी सूची diff --git a/fastlane/metadata/android/hr/title.txt b/fastlane/metadata/android/hr/title.txt new file mode 100644 index 00000000..3d0871a4 --- /dev/null +++ b/fastlane/metadata/android/hr/title.txt @@ -0,0 +1 @@ +OI Lista za kupovinu diff --git a/fastlane/metadata/android/ja/short_description.txt b/fastlane/metadata/android/ja/short_description.txt new file mode 100644 index 00000000..01ac168e --- /dev/null +++ b/fastlane/metadata/android/ja/short_description.txt @@ -0,0 +1 @@ +OI ショッピングリスト でショッピングアイテムを記録することができます。 diff --git a/fastlane/metadata/android/ja/title.txt b/fastlane/metadata/android/ja/title.txt new file mode 100644 index 00000000..3b8fb6c0 --- /dev/null +++ b/fastlane/metadata/android/ja/title.txt @@ -0,0 +1 @@ +OI ショッピングリスト diff --git a/fastlane/metadata/android/mk/title.txt b/fastlane/metadata/android/mk/title.txt new file mode 100644 index 00000000..aa1504be --- /dev/null +++ b/fastlane/metadata/android/mk/title.txt @@ -0,0 +1 @@ +OI Листа за купување diff --git a/fastlane/metadata/android/nb/short_description.txt b/fastlane/metadata/android/nb/short_description.txt new file mode 100644 index 00000000..cb9f1daf --- /dev/null +++ b/fastlane/metadata/android/nb/short_description.txt @@ -0,0 +1 @@ +Hold spor på dine handlepunkter med OI Handliste. diff --git a/fastlane/metadata/android/nb/title.txt b/fastlane/metadata/android/nb/title.txt new file mode 100644 index 00000000..06c4ffd5 --- /dev/null +++ b/fastlane/metadata/android/nb/title.txt @@ -0,0 +1 @@ +OI Handleliste diff --git a/fastlane/metadata/android/pa/short_description.txt b/fastlane/metadata/android/pa/short_description.txt new file mode 100644 index 00000000..9cb747ea --- /dev/null +++ b/fastlane/metadata/android/pa/short_description.txt @@ -0,0 +1 @@ +ਆਪਣੇ ਪੁਰਾਨਾ ਖਰੀਦਦਾਰੀ ਸੂਚੀ ਦੇ ਨਾਲ ਖਰੀਦਾਰੀ ਆਇਟਮ ਦਾ ਟ੍ਰੈਕ ਰੱਖੋ. diff --git a/fastlane/metadata/android/pa/title.txt b/fastlane/metadata/android/pa/title.txt new file mode 100644 index 00000000..f597b9ec --- /dev/null +++ b/fastlane/metadata/android/pa/title.txt @@ -0,0 +1 @@ +OI ਸ਼ੋਪਿੰਗ ਲਿਸਟ diff --git a/fastlane/metadata/android/pl/short_description.txt b/fastlane/metadata/android/pl/short_description.txt new file mode 100644 index 00000000..4ad94ff4 --- /dev/null +++ b/fastlane/metadata/android/pl/short_description.txt @@ -0,0 +1 @@ +Zarządzaj swoimi listami zakupów używając OI Lista zakupów. diff --git a/fastlane/metadata/android/pl/title.txt b/fastlane/metadata/android/pl/title.txt new file mode 100644 index 00000000..a9b66cdd --- /dev/null +++ b/fastlane/metadata/android/pl/title.txt @@ -0,0 +1 @@ +OI Lista zakupów diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt new file mode 100644 index 00000000..9b5c8543 --- /dev/null +++ b/fastlane/metadata/android/pt/short_description.txt @@ -0,0 +1 @@ +Registo de compras com o OI Shopping List. diff --git a/fastlane/metadata/android/ro/short_description.txt b/fastlane/metadata/android/ro/short_description.txt new file mode 100644 index 00000000..1d4e6bf5 --- /dev/null +++ b/fastlane/metadata/android/ro/short_description.txt @@ -0,0 +1 @@ +Țineți evidența articolelor de cumpărături cu Listă de cumpărături OI. diff --git a/fastlane/metadata/android/ro/title.txt b/fastlane/metadata/android/ro/title.txt new file mode 100644 index 00000000..d8d94d0c --- /dev/null +++ b/fastlane/metadata/android/ro/title.txt @@ -0,0 +1 @@ +Listă de cumpărături OI diff --git a/fastlane/metadata/android/sd/short_description.txt b/fastlane/metadata/android/sd/short_description.txt new file mode 100644 index 00000000..d6d0b714 --- /dev/null +++ b/fastlane/metadata/android/sd/short_description.txt @@ -0,0 +1 @@ +पेंजी खरीदारी जे सामान जो ट्रेक राख्यो OI शौपिंग लिस्ट सां गडद diff --git a/fastlane/metadata/android/sd/title.txt b/fastlane/metadata/android/sd/title.txt new file mode 100644 index 00000000..8bf55e68 --- /dev/null +++ b/fastlane/metadata/android/sd/title.txt @@ -0,0 +1 @@ +OI खरीदारी सूची diff --git a/fastlane/metadata/android/sk/short_description.txt b/fastlane/metadata/android/sk/short_description.txt new file mode 100644 index 00000000..08cf0420 --- /dev/null +++ b/fastlane/metadata/android/sk/short_description.txt @@ -0,0 +1 @@ +Sledujte svoje nákupné položky pomocou OI Nákupný Zoznam. diff --git a/fastlane/metadata/android/sk/title.txt b/fastlane/metadata/android/sk/title.txt new file mode 100644 index 00000000..98ad503c --- /dev/null +++ b/fastlane/metadata/android/sk/title.txt @@ -0,0 +1 @@ +OI Nákupný Zoznam diff --git a/fastlane/metadata/android/sr/short_description.txt b/fastlane/metadata/android/sr/short_description.txt new file mode 100644 index 00000000..699aef0b --- /dev/null +++ b/fastlane/metadata/android/sr/short_description.txt @@ -0,0 +1 @@ +Пратите куповне ставке са ОИ Куповном Листом diff --git a/fastlane/metadata/android/sr/title.txt b/fastlane/metadata/android/sr/title.txt new file mode 100644 index 00000000..991e72c1 --- /dev/null +++ b/fastlane/metadata/android/sr/title.txt @@ -0,0 +1 @@ +ОИ Куповна Листа diff --git a/fastlane/metadata/android/sv/short_description.txt b/fastlane/metadata/android/sv/short_description.txt new file mode 100644 index 00000000..890c5aaa --- /dev/null +++ b/fastlane/metadata/android/sv/short_description.txt @@ -0,0 +1 @@ +Håll koll på dina inköpsvaror med hjälp av OI Shopping List diff --git a/fastlane/metadata/android/ug/short_description.txt b/fastlane/metadata/android/ug/short_description.txt new file mode 100644 index 00000000..4219fea3 --- /dev/null +++ b/fastlane/metadata/android/ug/short_description.txt @@ -0,0 +1 @@ +مال تىزىمىڭىزدىكى تۈرلەرنى OI مال تىزىمىدا ئىز قوغلاشنى ساقلاپ قالىدۇ. diff --git a/fastlane/metadata/android/ug/title.txt b/fastlane/metadata/android/ug/title.txt new file mode 100644 index 00000000..5fb00636 --- /dev/null +++ b/fastlane/metadata/android/ug/title.txt @@ -0,0 +1 @@ +OI سېتىۋېلىش تىزىمى diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 00000000..fb64d61d --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +使用 OI 购物清单跟踪您购买的物品。 diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 100644 index 00000000..0c7fdacf --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +OI购物清单 diff --git a/zapstore.yaml b/zapstore.yaml new file mode 100644 index 00000000..36128199 --- /dev/null +++ b/zapstore.yaml @@ -0,0 +1,22 @@ +# Zapstore listing (https://zapstore.dev), published by .github/workflows/release.yml +# with `zsp publish zapstore.yaml`. The APK comes from the GitHub release of the tag. +repository: https://github.com/openintents/shoppinglist +match: tstore-release\.apk$ +name: OI Shopping List +summary: Simple, ad-free shopping list with list themes and prices per store +license: Apache-2.0 +website: https://github.com/openintents/shoppinglist +tags: + - shopping + - productivity + - lists +icon: ./fastlane/metadata/android/en-US/images/icon.png +images: + - ./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png + - ./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png + - ./fastlane/metadata/android/en-US/images/phoneScreenshots/3.png + - ./fastlane/metadata/android/en-US/images/phoneScreenshots/4.png + - ./fastlane/metadata/android/en-US/images/phoneScreenshots/5.png +release_notes: ./fastlane/metadata/android/en-US/changelogs/100230.txt +metadata_sources: + - fastlane