diff --git a/.github/workflows/check-doc-snippets.yml b/.github/workflows/check-doc-snippets.yml index f7130371a8a..10ea029923f 100644 --- a/.github/workflows/check-doc-snippets.yml +++ b/.github/workflows/check-doc-snippets.yml @@ -16,6 +16,7 @@ on: - 'scripts/wayline/**' - 'scripts/savor/**' - 'scripts/bastion/**' + - 'scripts/pulse/**' - 'scripts/skirmish/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'docs/website/content/blog/**' diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 8bfbe259398..4127941ddba 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -19,6 +19,7 @@ on: - 'scripts/savor/**' # The Bastion chapters, the same way; bastion.yml compiles and tests it. - 'scripts/bastion/**' + - 'scripts/pulse/**' - 'scripts/skirmish/**' - 'scripts/parley/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' @@ -142,6 +143,7 @@ jobs: - 'scripts/parley/**' - 'scripts/reel/**' - 'scripts/bastion/**' + - 'scripts/pulse/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/initializr-e2e.yml b/.github/workflows/initializr-e2e.yml index b6071be6086..e9b657fda9c 100644 --- a/.github/workflows/initializr-e2e.yml +++ b/.github/workflows/initializr-e2e.yml @@ -25,6 +25,7 @@ on: - 'scripts/parley/common/src/test/**' - 'scripts/reel/common/src/test/**' - 'scripts/bastion/common/src/test/**' + - 'scripts/pulse/common/src/test/**' schedule: - cron: '17 3 * * *' workflow_dispatch: @@ -44,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL] + template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL, PULSE] java: ['17'] # Developers download on current JDKs too: Kotlin 1.6.0 crashed on 25 # ("IllegalArgumentException: 25") while every 17 leg was green. diff --git a/.github/workflows/pulse.yml b/.github/workflows/pulse.yml new file mode 100644 index 00000000000..1fdd6ee2d4a --- /dev/null +++ b/.github/workflows/pulse.yml @@ -0,0 +1,575 @@ +name: Pulse (full-stack sample) + +# scripts/pulse is one Maven project holding a fitness studio application and +# its server: a `shared` module of transfer objects and @RestClient contracts, +# the Codename One client that calls them, and a Codename One Backend that +# implements them. +# +# Five things are checked here, because each fails without the others noticing: +# +# backend the server's own tests, on the JVM and compiled by ParparVM +# client-e2e the application in the simulator, driven through its real +# screens against a running server +# web the browser build of the application, hosted by the server +# (cn1:backend-webapp) and driven in a headless browser: it +# loads, signs in and reaches the home screen +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# template the Initializr's template is this project (template-sync), and +# a project generated from it builds (client-e2e) +# +# The project has the minimal layout, with no platform modules: common builds +# the simulator, the desktop, Android, iOS and the browser, and +# build-android-app.sh and build-ios-app.sh find the device projects there. +# +# The two device builds stop at a build: the screens are covered by the +# simulator run, and neither port holds screenshot baselines for this sample. + +on: + pull_request: + paths: + - 'scripts/pulse/**' + - 'vm/backend/src/**' + - 'vm/backend/impl/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'CodenameOne/src/com/codename1/maps/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + # The REST client maps its responses through the mapper registry. + - 'CodenameOne/src/com/codename1/mapping/**' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + # What the hosted web app runs on: the JavaScript port, both the + # browser side and the Java the app is compiled with (its network + # connection, its implementation class). The `web` job is the only + # test here that signs in through either. + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + - '.github/workflows/pulse.yml' + push: + branches: [ master ] + paths: + - 'scripts/pulse/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/initializr/common/src/**' + - '.github/workflows/pulse.yml' + workflow_dispatch: + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/pulse and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/pulse + run: python3 scripts/sync-initializr-template.py pulse + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/pulse + run: | + mvn -B -Dcodename1.platform=backend -pl shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/pulse/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: pulse-backend-tests + path: scripts/pulse/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/pulse \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: pulse-backend-${{ matrix.database }}-tests + path: scripts/pulse/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: Application against a running server (simulator) + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + # Retried only for a failure that is the network's: a compilation + # error has to fail on the first attempt. + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + bash ../scripts/ci/retry.sh mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (timeout 300 apt-get update && timeout 300 apt-get install -y xvfb) || true + fi + + # Ahead of the simulator run: it is quick, and a template that does not + # build should not wait behind the screens to say so. + - name: Build a project generated from the Initializr template + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/pulse/check-template.sh "$RUNNER_TEMP/pulse-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/pulse/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: pulse-e2e + path: | + scripts/pulse/target/e2e-shots/** + scripts/pulse/target/e2e-server.log + if-no-files-found: warn + retention-days: 14 + + web: + name: Web app hosted by the server (headless browser) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up Node 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('scripts/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium + run: | + cd scripts + npm ci + # Playwright shells out to apt for the OS-level packages, so the + # runner image's third-party sources are pruned first. + bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh" + npx playwright install-deps chromium + npx playwright install chromium + + # The JavaScript build is the plugin's own: the port and the translator + # travel inside it, so installing the plugin with what it is built from + # is all the browser build needs. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + # Builds the server and the browser build of the app, starts the server + # with the app staged where it serves it from, and signs in through the + # real screens. + - name: Run the hosted web app in a headless browser + run: scripts/pulse/run-web-e2e.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the screenshots and the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: pulse-web-e2e + path: scripts/pulse/target/web-e2e/** + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/pulse + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-pulse-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-pulse- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-pulse-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-pulse- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/pulse + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/pulse-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 90b782d0671..5f8639dd7f8 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -65,6 +65,7 @@ on: - 'scripts/wayline/**' - 'scripts/savor/**' - 'scripts/bastion/**' + - 'scripts/pulse/**' - 'scripts/skirmish/**' - 'scripts/parley/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' diff --git a/CodenameOne/src/com/codename1/ui/Sheet.java b/CodenameOne/src/com/codename1/ui/Sheet.java index a5812c98cd8..5969ceb9ca5 100644 --- a/CodenameOne/src/com/codename1/ui/Sheet.java +++ b/CodenameOne/src/com/codename1/ui/Sheet.java @@ -779,8 +779,14 @@ private void initUI() { contentPane.setSafeArea(true); titleBar.setSafeArea(true); add(BorderLayout.NORTH, titleBar); + // The button is an icon alone, so a screen reader (and anything else + // that finds controls by what they are called) would otherwise get an + // unnamed button. if (parentSheet != null) { FontImage.setMaterialIcon(backButton, FontImage.MATERIAL_ARROW_BACK); + backButton.setAccessibilityText(UIManager.getInstance().localize("Back", "Back")); + } else { + backButton.setAccessibilityText(UIManager.getInstance().localize("Close", "Close")); } add(BorderLayout.CENTER, contentPane); backButton.addActionListener(new ActionListener() { diff --git a/docs/demos/common/src/main/snippets/developer-guide/pulse.sh b/docs/demos/common/src/main/snippets/developer-guide/pulse.sh new file mode 100644 index 00000000000..2b4078d3e21 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/pulse.sh @@ -0,0 +1,31 @@ +// Snippets of the Pulse chapters of the developer guide. Edit them here, not inline. + +// tag::pulse-sh-server[] +cd scripts/pulse +CN1_PROFILE=dev backend/server.sh run +// end::pulse-sh-server[] + +// tag::pulse-sh-simulator[] +cd scripts/pulse +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::pulse-sh-simulator[] + +// tag::pulse-sh-web[] +cd scripts/pulse +backend/server.sh web +CN1_PROFILE=dev backend/server.sh run +// end::pulse-sh-web[] + +// tag::pulse-sh-tests[] +cd scripts/pulse +# The server's tests on the JVM, then compiled to a native binary +mvn -Dcodename1.platform=backend -pl shared,backend test -Dcn1.backend.compiledTests=true +# The same tests on PostgreSQL or MySQL +CN1_TEST_DATABASE_URL="postgres://user:password@localhost:5432/db?sslmode=disable" \ + ../ci/sample-server-tests-on-database.sh . +# The app against a running server, in the simulator +./run-e2e.sh +./run-e2e.sh --native +# The app in a headless browser, served by the server +./run-web-e2e.sh +// end::pulse-sh-tests[] diff --git a/docs/developer-guide/Pulse-Implementors-Guide.asciidoc b/docs/developer-guide/Pulse-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..d2acda78d73 --- /dev/null +++ b/docs/developer-guide/Pulse-Implementors-Guide.asciidoc @@ -0,0 +1,252 @@ +[[pulse-implementors-guide]] +== Pulse: Building on the sample + +Pulse shares its shape with <> and +<>: the module layout, accounts and sign-in, +cards and the payment provider, text messages, push notifications, the live +channel and hosting the app in a browser. This chapter covers what a studio +changes: households, a timetable that never oversells, a door pass, recurring +billing, and the marketing that runs by itself. + +[[pulse-launch]] +=== Running it + +The project is in `scripts/pulse`. Start the server on the development profile, +which seeds the demo studio and its accounts and simulates the cards: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-server,indent=0] +---- + +Then run the app in the simulator: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-simulator,indent=0] +---- + +=== The modules + +`shared` holds the data classes and the `@RestClient` interfaces both sides build +from, so a change one side didn't follow is a compile error. `common` is the app, +for every platform. `backend` is the server, in packages by subject: + +[cols="1,3"] +|=== +| Package | What it holds + +| `account` +| Accounts, phone verification, preferences and quiet hours, moderation, and the + demo accounts. + +| `studio` +| Locations, the timetable, households, bookings and the waitlist, door passes, + and the demo studio. + +| `desk` +| Check-in at the desk and the kiosk, member lookup, walk-ins and the desk's day. + +| `trainer` +| Rosters, attendance, shifts and live heart rates. + +| `billing` +| Memberships, invoices, proration, freezes, dunning and referral credits. + +| `pay` +| The payment provider: simulated or Stripe. + +| `engagement` +| Visit statistics, workouts, away periods and the automatic nudges. + +| `social` +| Friends, the feed, kudos and class invites. + +| `owner` +| The dashboard, plans, the timetable, staff and campaigns. + +| `live`, `push`, `sms` +| The WebSocket, push notifications and text messages. +|=== + +Roles are `MEMBER`, `TRAINER`, `DESK` and `OWNER`, in `Roles`. Every account is a +member; the others are granted by an owner. The kiosk is a desk account on a +tablet, so it needs no role of its own. + +=== A class that never oversells + +The rule that must never break is that a class isn't booked past its capacity, +however many people tap Book at once. `Bookings` leaves it to the database: the +places taken are a column of the class, and the only statement that raises it does +so on condition that it's still below capacity. A booking whose statement changed +no row joins the waitlist instead. Two requests for the last place both run that +statement, and the database makes the second find the class full. + +Everything else is checked first: the class is still to come, the person fits its +age range on the day, the waiver is signed, and a membership covers it. A place +given back promotes the first person on the waitlist, one promotion at a time per +class, and a job settles finished classes, turning the absent into no-shows. + +The same pattern takes a trainer's offered shift: two trainers tapping Take +together are told apart by one conditional statement. + +=== The door pass + +A pass on a phone is a QR code reading `pulse::`, where the code +is an HOTP (RFC 4226, SHA-256, eight digits) of the pass's secret over the number +of 30-second periods since the epoch. The phone and the server make the same +`com.codename1.security.Otp` call, so the phone needs no signal. The server takes +a code one period either side of now, for a phone whose clock is slightly off, and +keeps every code it accepted until it could no longer be valid, so a screenshot +shown twice is refused as a replay. + +An NFC id is the other kind of pass, for a fob or wristband. It doesn't change, so +it's as good as the thing it's written on; replacing the pass's secret replaces it +too. + +A check-in always answers, admitted or refused with a reason, and never fails for +a member who simply isn't allowed in. The kiosk turns that answer into a green or +a red screen. Each check-in is also sent to staff over the live channel. + +=== Billing + +`Billing` charges in three steps and only the middle one talks to the processor: +a transaction writes the invoice and claims it, the card is charged with no +transaction open, and a second transaction records the result. The invoice id is +the processor's idempotency key, so a server that dies between the charge and the +record charges the same invoice again on the next run and is answered the first +charge, not a second one. + +* *Proration.* A member moving plans has what's left of the old period taken off + the first invoice of the new one. +* *Freezes.* One week to three months. +* *The dunning ladder.* A declined renewal is retried after one, three and seven + days. From the second failure the membership is past due and the door says so; + after the last, the invoice waits to be paid by hand. +* *Referral credits.* A member whose referral first pays gets a credit. +* *Amounts* are `long` minor units in `pulse.currency`. + +The processor is behind `PaymentProvider`: a customer, a card saved to it once as +a token, and charges against that token with nobody present. The server keeps the +token, a brand and four digits, never a card number. `SimulatedPayments` takes the +test cards; `StripePayments` adds cards on Stripe's hosted page. Another processor +is one more implementation of the interface, chosen in `Services`. + +=== Nudges and campaigns + +The nudge job in `engagement` runs every 15 minutes and sends two kinds of message on its own: a class +starting in 3 to 36 hours that's under 60% full is offered to members who took +that kind of class before, at most three per empty place; and a member who hasn't +been in for 14 days is told the studio misses them, at most every 21 days. +Neither reaches a member who is away, one who turned promotions off, or one inside +their quiet hours; those are left for a later run. Each message is its own +transaction, checked again inside it, so two servers running the job send it once. + +Campaigns are the owner's: a message to those who took a kind of class, every +active member, or the lapsed, counted as sent, opened, and booked. + +=== Live heart rates + +A member's watch sends heart rate readings during a class. `HeartRates` keeps the +latest per member in memory only: a reading matters for a few seconds, and the +workout, which is kept, arrives whole when it ends. The zone, one to five, is the +reading's share of 220 less the member's age, in fifths from 50%. Readings go to +the class's trainer over the live channel. + +The live channel carries four frames: a class's places changing, one of an +account's bookings changing, a heart rate, and a check-in. The connections are +those of one process; running more than one instance needs a channel between them, +such as a database `LISTEN`/`NOTIFY` or a message broker, feeding the same +methods. + +=== Settings + +`backend/application.properties` lists every setting. A deployment sets the +database (`DATABASE_URL`), the issuer and signing key, the first owner +(`pulse.admin.email`, `pulse.admin.password`), and the providers: + +[cols="1,3"] +|=== +| Setting | Choices + +| `pulse.sms.provider` +| `twilio`, `vonage`, `messagebird`, or `console` on a development profile. + +| `pulse.payments.provider` +| `stripe`, or `simulated` on a development profile. + +| `pulse.push.provider` +| `cloud` (with `pulse.push.app` and `pulse.push.key`), or `console` and + `recording` on development profiles. + +| `pulse.currency`, `pulse.timezone` +| The studio's currency and the time zone quiet hours are counted in. +|=== + +Unset, each provider is the development one on a development profile and the real +one elsewhere. A real provider with missing settings is logged at start-up, naming +them, and every call to it fails with the same message; nothing falls back to a +development stand-in. Secrets go in the environment, in upper case with `_` for +each dot (`PULSE_PAYMENTS_STRIPE_SECRET`). + +=== Tests + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-tests,indent=0] +---- + +The server's tests run on in-memory SQLite, which accepts any value in any column. +`sample-server-tests-on-database.sh` runs them against PostgreSQL or MySQL, where +the ORM checks every mapping against its migration. CI runs both, in +`.github/workflows/pulse.yml`. + +=== Making it yours + +* *Your studio.* Replace `DemoStudio` with your locations, rooms, class types and + plans, or create them from the owner's screens on an empty database. +* *Your brand.* Colors and shapes are in `common/src/main/css/theme.css`, text in + `common/src/main/l10n`. +* *Your rules.* The late-cancellation window, the dunning ladder, freeze limits, + the referral credit and the nudge thresholds are constants beside the code that + uses them, in `Bookings`, `Billing` and the nudge job in `engagement`. +* *A chain of studios.* Locations are already a dimension of the timetable and the + desk; a chain adds location-scoped staff roles and reporting. + +[[pulse-before-product]] +=== Before it's a product + +The sample is complete as a demonstration and stops where a studio's legal and +physical systems begin. A product needs at least: + +* *A real payment processor and PCI scope.* Wire `PaymentProvider` to your + processor and test it with its test keys. Keep card entry on the processor's + hosted page so card numbers never reach your app or server, and confirm your PCI + DSS self-assessment with the processor. Add receipts, tax, refunds and the + disputes process your market expects. +* *Waivers and medical consent that hold up.* The waiver is a flag and a date. A + binding waiver needs the text your lawyer approves for your jurisdiction, a + record of exactly which version was signed, by whom and how, and a health + questionnaire or medical consent where the law or your insurer asks for one. + Minors usually need a parent or guardian to consent for them. +* *Child safeguarding.* Children's classes need background checks for the staff who + teach them, rules on who may collect a child, consent for photos, and limits on + what other members can see about a child. Check what your country's safeguarding + rules require. +* *Door hardware.* The kiosk decides; it doesn't open anything. A real door needs + an access controller and reader, integrated through their vendor's API, with a + fail-safe for fire exits and a plan for when the network is down. +* *Health data privacy.* Heart rates and workouts are health data. Under GDPR + that's special category data, needing explicit consent, a stated purpose, a + retention limit and deletion on request. In the United States, check whether + your service falls under HIPAA (for example through a healthcare partner) and + what state privacy laws ask. Follow Apple's and Google's rules for data read from + HealthKit and Health Connect. +* *Marketing consent.* Nudges and campaigns respect the promotion switch and quiet + hours. Check your market's rules on marketing messages, such as opt-in + requirements and an unsubscribe link in every message. +* *Reviewed translations.* The non-English bundles are machine translations; have + them reviewed before release. +* *Operations.* Monitoring, backups that have been restored in a test, a support + process for members, and a channel between server instances before you run more + than one. diff --git a/docs/developer-guide/Pulse-Users-Guide.asciidoc b/docs/developer-guide/Pulse-Users-Guide.asciidoc new file mode 100644 index 00000000000..2cb8375ba1b --- /dev/null +++ b/docs/developer-guide/Pulse-Users-Guide.asciidoc @@ -0,0 +1,466 @@ +[[pulse-users-guide]] +== Pulse: A fitness studio app from every side + +Pulse is a sample app with a server behind it, for a gym or a studio that runs +classes. Members book classes for themselves and their children, open the door +with their phone, pay for a membership and train with their friends. Trainers run +their classes, the front desk lets people in, and the owner reads the numbers and +fills the quiet classes. The app and the server are one Maven project, written in +Java on both sides. + +This chapter is the user's guide: what each kind of user sees and can do. +<> is the other half. It explains how the project is +built and what to change to make it yours. + +One app serves everyone. The server gives every account a set of roles, and the +app offers a mode for each role the account holds. Someone who holds more than one +switches between them from the menu. + +[cols="1,3"] +|=== +| Role | What it adds + +| Member +| Every account is a member. Book classes for yourself and your household, show + the door pass, manage the membership, say when you're away, see your visits and + workouts, and keep up with friends. + +| Trainer +| Granted by the owner. See the week's classes and who's coming, mark + attendance, follow heart rate zones live in class, and swap shifts. + +| Front desk +| Granted by the owner. Check people in by pass, fob or name, take walk-ins, and + read the day at a location. A tablet signed in as the desk runs as the kiosk by + the door. + +| Owner +| Runs the studio: the dashboard, plans, the timetable, staff, campaigns and + accounts. +|=== + +=== Trying it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, text messages and push notifications are printed on the +console, and cards are simulated. + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-server,indent=0] +---- + +Then run the app in the simulator, from another terminal: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-simulator,indent=0] +---- + +The development profile seeds a studio with two locations, a timetable of adult +and children's classes from three weeks back to three weeks ahead, the plans on +sale, and a few weeks of bookings, visits and paid invoices, so every screen has +something to show. Every demo account has the password `pulse-demo`: + +[cols="2,3"] +|=== +| Account | Who + +| `member@pulse.example` +| A member with two children in the household and a card on file. + +| `member2@pulse.example` +| A new member: no household and no card. + +| `member3@pulse.example` +| A member the owner has flagged. + +| `member4@pulse.example` +| A member who hasn't been in for weeks. + +| `trainer@pulse.example`, `trainer2@pulse.example` +| Trainers. + +| `desk@pulse.example` +| The front desk and the door kiosk. + +| `owner@pulse.example` +| The owner. +|=== + +The simulated card `4242 4242 4242 4242` is accepted and `4000 0000 0000 0002` +is declined. No money moves. + +=== Getting in + +A new member registers with a name, an e-mail address, a password and a phone +number, which is confirmed with a code sent by text message. On the development +profile the code is printed on the server's console and filled in for you. Before +the first booking the app asks the account holder to sign the studio's waiver, for +themselves and for each child they add. + +image::img/pulse-welcome.png["The Pulse welcome screen with buttons to sign in and to create an account",scaledwidth=40%] + +=== The member + +The member's home leads with the next class booked, with the time, the trainer and +the room, and how long until it starts. Under it are this month's visits, the +week streak, the points and level, and the badges. + +image::img/pulse-member-home.png["The member's home with the next class, the month's visits, the points and level, and the shelf of badges",scaledwidth=40%] + +==== The timetable and booking + +The timetable lists the classes at each location by day, with the places left, +which update live as other members book. Pills at the top pick the day and the +person in the household, and picking a person shows only the classes open to them. Booking takes a place if one is free and +joins the waitlist if not. When someone gives a place back, the first person +waiting is moved into the class and told so at once. + +A place given back at least 12 hours before the class is free. Later than that it +counts as a late cancellation, and a class from a pack is spent. Someone booked +who never comes is marked a no-show once the class is over. + +A booking is refused when the class has already started, when the person is +outside the class's age range on the day of the class, when the waiver isn't +signed, or when no membership covers the class. + +image::img/pulse-timetable.png["The timetable for one day, each class a card with its time, trainer, room and the places left",scaledwidth=40%] + +==== The household + +An account holder books for up to eight people: themselves and their children. +Each child has their own membership, their own place in a class and their own door +pass, but only the account holder signs in. A child's age is worked out from their +date of birth on the day of each class, so a child grows into the next age band +without anyone changing anything. + +==== The door pass + +The pass is a QR code that changes every 30 seconds. It's computed on the phone, +so it works with no signal. A pass is good once: a screenshot shown a second time +is refused as already used. A child without a phone gets an NFC fob or wristband +instead. If a phone is lost, replacing the pass makes the old codes and the old +fob stop opening the door at once. + +image::img/pulse-pass.png["The door pass: a QR code with a bar counting down to the next code",scaledwidth=40%] + +==== The membership + +The plans on sale are monthly, annual, class packs and drop-ins. Joining takes the +first payment; changing plan takes what's left of the old period off the first +invoice. A membership can be frozen for one week to three months, and cancelled. + +When a renewal is declined the card is tried again after one, three and seven +days. The membership stays usable through the first retry; from the second +failure it's past due and the door says so; after the last retry the invoice +waits to be paid by hand from the wallet. A member who brings a friend gets a +credit when the friend first pays. + +==== Away periods + +A member who is travelling or injured says when they'll be away. No marketing +reaches them meanwhile, and they can choose to freeze their billing for the same +time. + +==== Visits, workouts and friends + +The member's page shows this month's visits, the streak of weeks with a visit, and +the last twelve weeks. Workouts recorded by the phone's health store or the watch +are kept with their heart rate. A member can share their heart rate with the +trainer during a class. + +image::img/pulse-workouts.png["The workouts list with the duration, average heart rate and time in each zone of every workout",scaledwidth=40%] + +==== Points, levels and badges + +Coming to a class, recording a workout and keeping a weekly streak earn points, +and points raise the member's level. A workout recorded with a heart-rate strap or +a watch earns extra points, since it shows how hard the member worked. +Badges mark milestones: the first class, ten and fifty classes, a four- and a +twelve-week streak, a first workout with heart rate, a class worn with a strap or a +watch, a minute in zone 5, and ten hours of workouts. Tapping a badge says how it +is earned. + +Friends are added by the e-mail they sign in with, and nothing about a member is +shown to someone they haven't accepted. The feed shows friends' visits and the +workouts they chose to share, kudos can be given for them, and a friend can be +invited to a class. + +image::img/pulse-social.png["The friends feed with friends' visits and shared workouts, each with its kudos",scaledwidth=40%] + +The timetable shows which friends are booked into each class. + +==== Messages and quiet hours + +Two kinds of message arrive without anyone sending them: an invitation to a class +starting within a day and a half that's still under 60% full, sent to members who +have taken that kind of class before, and a reminder that the studio misses them after two weeks without +a visit. Neither is sent to a member who is away or who turned promotions off, and +neither arrives during quiet hours, which are 10 PM to 7 AM until the member +changes them in the settings. + +image::img/pulse-settings.png["The settings with the theme, the language, notifications and quiet hours",scaledwidth=40%] + +==== The watch + +On a paired watch, Pulse is four pages, swiped sideways, and a fifth for a trainer. The first is the next class: +its name, a countdown, the room and the trainer, and a bar of how full it is. + +image::img/pulse-watch.png["The watch's first page with the next class, the time until it starts, the room, the trainer and how full the class is",scaledwidth=25%] + +The second is the door code, with a bar counting down to the next one. The code is +the same one the phone's pass shows, so the watch opens the door with the phone +left in the locker. + +image::img/pulse-watch-door.png["The watch's door code with the member's name and a bar counting down to the next code",scaledwidth=25%] + +The third is the week: a ring of the points toward the next level, the points still +needed, the week streak and the visits of the last eight weeks as bars. + +image::img/pulse-watch-week.png["The watch's week page with a ring of points toward the next level, the streak and eight weeks of visits",scaledwidth=25%] + +The last lists the classes booked after the next one. + +image::img/pulse-watch-agenda.png["The watch's list of the classes booked in the coming days",scaledwidth=25%] + +A trainer's watch adds a coaching page: the class they teach now or next, the +minutes until it starts or until it ends, the room and its places taken, and the +trainer's level and place among the studio's coaches. + +image::img/pulse-watch-coach.png["The watch's coaching page with the class on now, the minutes left, the places taken and the coach's level and rank",scaledwidth=25%] + +=== The trainer + +The trainer's home leads with the class on now, or the next one, on a dark card with +a button for the live view and one for the roster. + +Under the card is the trainer's own game. Members earn points for coming; a trainer earns +them for the people they coach: 10 for each check-in to their classes, 5 for each +kudos on a workout from them, and 25 for a class with every place booked. The points +count over the last eight weeks, so a level reflects how the trainer is coaching now +rather than how they once did, and every 400 points is a level. The ring shows the +level and the way to the next one, and next to it are the weeks running in which the +trainer taught, and their place among the studio's coaches. + +image::img/pulse-trainer-home.png["The trainer's home with the class on now, the coach's level ring with points, streak and rank, and the coach badges",scaledwidth=40%] + +The badges reward what a studio needs from a coach: a full class, five full classes in +four weeks, ten regulars, 25 kudos, nine in ten booked members turning up, eight weeks +of teaching without a gap, a hundred check-ins, and being the studio's top coach. +Tapping a badge says how it's earned and whether it is. + +image::img/pulse-trainer-badge.png["A coach badge opened, saying what it takes to earn it and that it's earned",scaledwidth=40%] + +The leaderboard ranks every coach who taught in the last four weeks by the people +who came, with the trainer's own row marked. The trainer's month follows: the +classes taught, the check-ins, how full the classes were and how many of those +booked came, the regulars who came three times or more, and the kudos received. A +chart shows the check-ins of each of the last eight weeks, and under it the kinds of +class that drew the most people. + +image::img/pulse-trainer-month.png["The coaches' leaderboard and the trainer's month: classes, check-ins, places filled, attendance, regulars, kudos and check-ins a week",scaledwidth=40%] + +The week's classes follow, a day at a time, each with how many places are taken, +and a full class says so. The roster of each class shows the members booked, the +ones waiting and the ones who came, with what the trainer needs to know about each +person: a first visit, their age, their heart rate, and any medical note. +Attendance can be marked by hand for someone the door didn't see, and corrected for +30 minutes after the class ends. + +image::img/pulse-roster.png["The roster of a class with each member's age and heart rate and a switch for attendance",scaledwidth=40%] + +During a class, members who share their heart rate appear with their current zone, +one to five, worked out from their age. + +image::img/pulse-live.png["The live class view with each member's heart rate and zone",scaledwidth=40%] + +The trainer's shifts list the ones to come. A trainer offers a shift they can't work +to the others, withdraws the offer while nobody has taken it, and takes a shift +another trainer offered. + +image::img/pulse-trainer-shifts.png["The trainer's shifts with a button to offer each, and a shift another trainer offered with a button to take it",scaledwidth=40%] + +=== The front desk and the kiosk + +The desk checks people in by scanning the pass, reading a fob, or finding the +member by name. Every check-in answers: admitted, or refused with the reason to +read out, such as a membership that's past due or a class the person isn't booked +into. A booking counts for a visit from an hour before its class until 30 minutes +after it starts. + +Someone who comes in without a membership is recorded as a walk-in: a guest, a day +pass or a free trial, paid in cash, by card or not at all. The owner's campaigns +follow up on them. + +The desk's day shows who came, what was taken and what starts soon. Signed in on a +tablet by the door, the desk account runs as the kiosk: a member holds up their +pass and the screen turns green or red. + +The desk's home opens with two large buttons, one to scan a pass and one to read a +fob, and a field to type a pass number. Under them are today's check-ins, walk-ins +and takings, the classes coming up, and the people who just came in, admitted or +turned away. + +image::img/pulse-desk.png["The front desk's home with today's numbers, the classes coming up and the latest check-ins",scaledwidth=40%] + +=== The owner + +The dashboard opens with the recurring revenue a month, the members, the share of +places taken in the last week's classes, and the members who haven't been in for +two weeks and didn't say they'd be away. Under them are who joined and who left in +the last 30 days, the visits this week and the invoices past due; the takings of +each day for the last 30 days; the kinds of class booked most, ranked; and the +classes in the next two days that are under half full, each with a button to fill +it. + +image::img/pulse-owner.png["The owner's dashboard with revenue, how full the classes were, members at risk and the quiet classes ahead",scaledwidth=40%] + +==== Insights + +The Insights screen is where the owner looks further back. It opens with four numbers: the +share of members who joined 90 days ago or more and are still members, the visits +of a member in a week, the share of bookings nobody came to, and the monthly +recurring revenue of a member. + +The busy hours are a grid of the days of the week by the hours the studio is open, +each cell as dark as the check-ins in that hour over the last eight weeks. Under it, +the membership: how many members there are against twelve weeks ago, a line of the +active members of each week, and bars of who joined and who left. + +image::img/pulse-insights.png["Insights with retention, visits, no-shows and revenue a member, the busy hours grid and the membership over twelve weeks",scaledwidth=40%] + +The classes are ranked by how full they ran, each with its share of no-shows. The +plan mix is a ring of the members on each plan, with what each plan brings in a +month; a class pack is paid by the pack, so it says that instead. + +image::img/pulse-insights-classes.png["The classes ranked by how full they ran, with the share of no-shows of each",scaledwidth=40%] + +The coaches are ranked by their check-ins, each with their classes, how +full those were, how many booked came, and their kudos. + +image::img/pulse-insights-coaches.png["The plans with their members and monthly revenue, and the coaches ranked with their classes, fill, attendance and kudos",scaledwidth=40%] + +==== Members + +The members list searches by name and filters to the members at risk, past due or +frozen. Each row shows the plan, the last visit and the visits in 30 days, and a pill +that says where the member stands. + +image::img/pulse-members.png["The members list with the plan, the last visit, the visits in 30 days and the state of each member",scaledwidth=40%] + +image::img/pulse-members-at-risk.png["The members list filtered to the members at risk",scaledwidth=40%] + +A member's page has the visits in 30 days, the week streak and what the member has +paid in all, a chart of the visits of each of the last twelve weeks, the classes +they come to most, and their invoices. + +image::img/pulse-member-detail.png["A member's page with visits, streak and lifetime value, visits a week and favourite classes",scaledwidth=40%] + +==== Billing + +Billing totals what was collected in the last 30 days, what's outstanding and how +many charges failed, and lists the invoices, filtered to paid, failed, or open. A +failed invoice shows which attempt it's on and when the next one runs; the owner can +void a failed or open invoice, with a reason. + +image::img/pulse-billing.png["Billing with the money collected, outstanding and failed, and the list of invoices",scaledwidth=40%] + +==== Plans, classes and the timetable + +The plans list every way to pay: drop-ins, monthly and annual memberships, class +packs, and plans for children or off-peak hours. The owner adds and changes them, +and a change of price reaches members at their next renewal. + +image::img/pulse-plans.png["The plans with the price and how each is paid",scaledwidth=40%] + +The class templates are the kinds of class the studio gives, each with its length, +its places, and how hard it is. A template is what the timetable schedules: the +owner puts a class on the timetable for a number of weeks, with its trainer and its +room, and cancels a single class with a reason the members booked into it are told. + +image::img/pulse-templates.png["The class templates with the length, places and intensity of each",scaledwidth=40%] + +image::img/pulse-owner-timetable.png["The owner's timetable for scheduling classes",scaledwidth=40%] + +==== Staff and shifts + +The staff list shows each person's title and their classes this week, and the owner +gives or takes away their roles. The shifts are the whole studio's rota a day at a +time: who works, the duty, the hours, and the site when there is more than one. A +shift a trainer offered to the others is marked, and the owner adds shifts. + +image::img/pulse-staff.png["The staff with their titles, classes this week and roles",scaledwidth=40%] + +image::img/pulse-owner-shifts.png["The rota a day at a time with who works, the duty, the hours and the site",scaledwidth=40%] + +==== Campaigns + +A campaign goes to everyone who took a kind of class, to every active member, or to +the members who haven't been in for a month, as a push notification and a message in +the member's inbox. Each campaign counts how many were sent, opened, and booked. The +dashboard's quiet classes and its members at risk each open a new campaign aimed at +them. + +image::img/pulse-campaigns.png["The campaigns with what was sent, opened and booked for each",scaledwidth=40%] + +image::img/pulse-campaign-new.png["A new campaign with its audience, its name, title and message",scaledwidth=40%] + +==== Accounts + +Accounts is the administration of everyone who can sign in: members and staff +alike, searched by name or e-mail and filtered to the staff, the flagged, or the +blocked. Each row carries the account's highest role or its standing. + +image::img/pulse-accounts.png["The accounts filtered to the staff, each with their role",scaledwidth=40%] + +An account's page has its visits, its no-shows and what it has spent, its roles as +switches, the people in its household with any missing waiver, and its history: +every flag, block and change of roles, with who did it, when, and why. An owner can't +take away their own owner role, which would leave nobody to give it back. + +image::img/pulse-account.png["An account flagged for no-shows, with its visits, no-shows and spending and its roles",scaledwidth=40%] + +The owner flags an account for the other owners to look at, which leaves it working, +or blocks it, which signs it out at once and stops it signing in, booking, or +checking in. Both ask for a reason, which the history keeps. + +image::img/pulse-account-block.png["Blocking an account, with the reason the record will keep",scaledwidth=40%] + +=== On a desktop + +The front office usually runs on a computer, so Pulse rearranges itself on a wide +window: the desktop app, a tablet, or a browser window wider than a phone. The +menu stays open down the left edge, with the current screen highlighted, and the +dashboards spread into two columns. + +image::img/pulse-desktop-owner.png["The owner's dashboard on a desktop: the menu docked on the left, eight figures in two rows, takings and popular classes beside the links and the under-filled classes",scaledwidth=90%] + +image::img/pulse-desktop-insights.png["Insights on a desktop: busy hours and membership growth on the left, class fill, plan mix and coaches on the right",scaledwidth=90%] + +Lists become master and detail. Picking a member or an account opens it next to +the list rather than on a screen of its own, and the search and filters stay +where they were. + +image::img/pulse-desktop-members.png["Members on a desktop: the searchable list on the left and the member picked, with visits, favourite classes and invoices, on the right",scaledwidth=90%] + +image::img/pulse-desktop-accounts.png["Accounts on a desktop: the list on the left and the account picked, with its roles, household and history, on the right",scaledwidth=90%] + +The trainer and the member get the same docked menu, with their home centered +next to it. + +image::img/pulse-desktop-trainer.png["The trainer's home on a desktop, with the class on now, the coach's game and the leaderboard",scaledwidth=90%] + +image::img/pulse-desktop-member.png["The member's home on a desktop, with the next class, the shortcuts, the level ring and the month",scaledwidth=90%] + +=== In a browser + +The server can host the app itself, so the whole studio can be tried in a browser: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/pulse.sh[tag=pulse-sh-web,indent=0] +---- + +Then open `http://localhost:8080/`. A browser offers less than a phone: the door +pass, health data and the watch are features of the phone app. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 34ddb485cc5..a678e0f80b5 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -170,6 +170,10 @@ include::Bastion-Users-Guide.asciidoc[] include::Bastion-Implementors-Guide.asciidoc[] +include::Pulse-Users-Guide.asciidoc[] + +include::Pulse-Implementors-Guide.asciidoc[] + = Device and platform services include::Push-Notifications.asciidoc[] diff --git a/docs/developer-guide/img/pulse-account-block.png b/docs/developer-guide/img/pulse-account-block.png new file mode 100644 index 00000000000..8b13c017041 Binary files /dev/null and b/docs/developer-guide/img/pulse-account-block.png differ diff --git a/docs/developer-guide/img/pulse-account.png b/docs/developer-guide/img/pulse-account.png new file mode 100644 index 00000000000..4482a41425a Binary files /dev/null and b/docs/developer-guide/img/pulse-account.png differ diff --git a/docs/developer-guide/img/pulse-accounts.png b/docs/developer-guide/img/pulse-accounts.png new file mode 100644 index 00000000000..4a92fd9e96e Binary files /dev/null and b/docs/developer-guide/img/pulse-accounts.png differ diff --git a/docs/developer-guide/img/pulse-billing.png b/docs/developer-guide/img/pulse-billing.png new file mode 100644 index 00000000000..91293650e7b Binary files /dev/null and b/docs/developer-guide/img/pulse-billing.png differ diff --git a/docs/developer-guide/img/pulse-campaign-new.png b/docs/developer-guide/img/pulse-campaign-new.png new file mode 100644 index 00000000000..c68de0b6905 Binary files /dev/null and b/docs/developer-guide/img/pulse-campaign-new.png differ diff --git a/docs/developer-guide/img/pulse-campaigns.png b/docs/developer-guide/img/pulse-campaigns.png new file mode 100644 index 00000000000..fc3445c466f Binary files /dev/null and b/docs/developer-guide/img/pulse-campaigns.png differ diff --git a/docs/developer-guide/img/pulse-desk.png b/docs/developer-guide/img/pulse-desk.png new file mode 100644 index 00000000000..9b2a095dcf5 Binary files /dev/null and b/docs/developer-guide/img/pulse-desk.png differ diff --git a/docs/developer-guide/img/pulse-desktop-accounts.png b/docs/developer-guide/img/pulse-desktop-accounts.png new file mode 100644 index 00000000000..f4ecbae1373 Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-accounts.png differ diff --git a/docs/developer-guide/img/pulse-desktop-insights.png b/docs/developer-guide/img/pulse-desktop-insights.png new file mode 100644 index 00000000000..ac292aff542 Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-insights.png differ diff --git a/docs/developer-guide/img/pulse-desktop-member.png b/docs/developer-guide/img/pulse-desktop-member.png new file mode 100644 index 00000000000..f46eb5648ca Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-member.png differ diff --git a/docs/developer-guide/img/pulse-desktop-members.png b/docs/developer-guide/img/pulse-desktop-members.png new file mode 100644 index 00000000000..9104733b94c Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-members.png differ diff --git a/docs/developer-guide/img/pulse-desktop-owner.png b/docs/developer-guide/img/pulse-desktop-owner.png new file mode 100644 index 00000000000..7f92e52961c Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-owner.png differ diff --git a/docs/developer-guide/img/pulse-desktop-trainer.png b/docs/developer-guide/img/pulse-desktop-trainer.png new file mode 100644 index 00000000000..484338c9b91 Binary files /dev/null and b/docs/developer-guide/img/pulse-desktop-trainer.png differ diff --git a/docs/developer-guide/img/pulse-insights-classes.png b/docs/developer-guide/img/pulse-insights-classes.png new file mode 100644 index 00000000000..fac737d6f98 Binary files /dev/null and b/docs/developer-guide/img/pulse-insights-classes.png differ diff --git a/docs/developer-guide/img/pulse-insights-coaches.png b/docs/developer-guide/img/pulse-insights-coaches.png new file mode 100644 index 00000000000..89154350d49 Binary files /dev/null and b/docs/developer-guide/img/pulse-insights-coaches.png differ diff --git a/docs/developer-guide/img/pulse-insights.png b/docs/developer-guide/img/pulse-insights.png new file mode 100644 index 00000000000..4ba7c223fd6 Binary files /dev/null and b/docs/developer-guide/img/pulse-insights.png differ diff --git a/docs/developer-guide/img/pulse-live.png b/docs/developer-guide/img/pulse-live.png new file mode 100644 index 00000000000..5c1872ae4e3 Binary files /dev/null and b/docs/developer-guide/img/pulse-live.png differ diff --git a/docs/developer-guide/img/pulse-member-detail.png b/docs/developer-guide/img/pulse-member-detail.png new file mode 100644 index 00000000000..640a5e4e23a Binary files /dev/null and b/docs/developer-guide/img/pulse-member-detail.png differ diff --git a/docs/developer-guide/img/pulse-member-home.png b/docs/developer-guide/img/pulse-member-home.png new file mode 100644 index 00000000000..e29a1fb1aa5 Binary files /dev/null and b/docs/developer-guide/img/pulse-member-home.png differ diff --git a/docs/developer-guide/img/pulse-members-at-risk.png b/docs/developer-guide/img/pulse-members-at-risk.png new file mode 100644 index 00000000000..02745ee2ae4 Binary files /dev/null and b/docs/developer-guide/img/pulse-members-at-risk.png differ diff --git a/docs/developer-guide/img/pulse-members.png b/docs/developer-guide/img/pulse-members.png new file mode 100644 index 00000000000..81635f80de8 Binary files /dev/null and b/docs/developer-guide/img/pulse-members.png differ diff --git a/docs/developer-guide/img/pulse-owner-shifts.png b/docs/developer-guide/img/pulse-owner-shifts.png new file mode 100644 index 00000000000..4dc3e81cfaf Binary files /dev/null and b/docs/developer-guide/img/pulse-owner-shifts.png differ diff --git a/docs/developer-guide/img/pulse-owner-timetable.png b/docs/developer-guide/img/pulse-owner-timetable.png new file mode 100644 index 00000000000..c6b01288ca6 Binary files /dev/null and b/docs/developer-guide/img/pulse-owner-timetable.png differ diff --git a/docs/developer-guide/img/pulse-owner.png b/docs/developer-guide/img/pulse-owner.png new file mode 100644 index 00000000000..79dedb407ad Binary files /dev/null and b/docs/developer-guide/img/pulse-owner.png differ diff --git a/docs/developer-guide/img/pulse-pass.png b/docs/developer-guide/img/pulse-pass.png new file mode 100644 index 00000000000..1377783aa7c Binary files /dev/null and b/docs/developer-guide/img/pulse-pass.png differ diff --git a/docs/developer-guide/img/pulse-plans.png b/docs/developer-guide/img/pulse-plans.png new file mode 100644 index 00000000000..c004a595bac Binary files /dev/null and b/docs/developer-guide/img/pulse-plans.png differ diff --git a/docs/developer-guide/img/pulse-roster.png b/docs/developer-guide/img/pulse-roster.png new file mode 100644 index 00000000000..978344042de Binary files /dev/null and b/docs/developer-guide/img/pulse-roster.png differ diff --git a/docs/developer-guide/img/pulse-settings.png b/docs/developer-guide/img/pulse-settings.png new file mode 100644 index 00000000000..1e173814b9c Binary files /dev/null and b/docs/developer-guide/img/pulse-settings.png differ diff --git a/docs/developer-guide/img/pulse-social.png b/docs/developer-guide/img/pulse-social.png new file mode 100644 index 00000000000..4062033a492 Binary files /dev/null and b/docs/developer-guide/img/pulse-social.png differ diff --git a/docs/developer-guide/img/pulse-staff.png b/docs/developer-guide/img/pulse-staff.png new file mode 100644 index 00000000000..c1291fb9626 Binary files /dev/null and b/docs/developer-guide/img/pulse-staff.png differ diff --git a/docs/developer-guide/img/pulse-templates.png b/docs/developer-guide/img/pulse-templates.png new file mode 100644 index 00000000000..8ec5a0a2cf2 Binary files /dev/null and b/docs/developer-guide/img/pulse-templates.png differ diff --git a/docs/developer-guide/img/pulse-timetable.png b/docs/developer-guide/img/pulse-timetable.png new file mode 100644 index 00000000000..7b6d0bd8d37 Binary files /dev/null and b/docs/developer-guide/img/pulse-timetable.png differ diff --git a/docs/developer-guide/img/pulse-trainer-badge.png b/docs/developer-guide/img/pulse-trainer-badge.png new file mode 100644 index 00000000000..54e6e8a962e Binary files /dev/null and b/docs/developer-guide/img/pulse-trainer-badge.png differ diff --git a/docs/developer-guide/img/pulse-trainer-home.png b/docs/developer-guide/img/pulse-trainer-home.png new file mode 100644 index 00000000000..cd39d1150b8 Binary files /dev/null and b/docs/developer-guide/img/pulse-trainer-home.png differ diff --git a/docs/developer-guide/img/pulse-trainer-month.png b/docs/developer-guide/img/pulse-trainer-month.png new file mode 100644 index 00000000000..264989a1b03 Binary files /dev/null and b/docs/developer-guide/img/pulse-trainer-month.png differ diff --git a/docs/developer-guide/img/pulse-trainer-shifts.png b/docs/developer-guide/img/pulse-trainer-shifts.png new file mode 100644 index 00000000000..eccce03b044 Binary files /dev/null and b/docs/developer-guide/img/pulse-trainer-shifts.png differ diff --git a/docs/developer-guide/img/pulse-watch-agenda.png b/docs/developer-guide/img/pulse-watch-agenda.png new file mode 100644 index 00000000000..d2e6717971a Binary files /dev/null and b/docs/developer-guide/img/pulse-watch-agenda.png differ diff --git a/docs/developer-guide/img/pulse-watch-coach.png b/docs/developer-guide/img/pulse-watch-coach.png new file mode 100644 index 00000000000..8a7a91242b8 Binary files /dev/null and b/docs/developer-guide/img/pulse-watch-coach.png differ diff --git a/docs/developer-guide/img/pulse-watch-door.png b/docs/developer-guide/img/pulse-watch-door.png new file mode 100644 index 00000000000..1cb70876725 Binary files /dev/null and b/docs/developer-guide/img/pulse-watch-door.png differ diff --git a/docs/developer-guide/img/pulse-watch-week.png b/docs/developer-guide/img/pulse-watch-week.png new file mode 100644 index 00000000000..16c304fdf8e Binary files /dev/null and b/docs/developer-guide/img/pulse-watch-week.png differ diff --git a/docs/developer-guide/img/pulse-watch.png b/docs/developer-guide/img/pulse-watch.png new file mode 100644 index 00000000000..1c8f1529649 Binary files /dev/null and b/docs/developer-guide/img/pulse-watch.png differ diff --git a/docs/developer-guide/img/pulse-welcome.png b/docs/developer-guide/img/pulse-welcome.png new file mode 100644 index 00000000000..dc983605ce8 Binary files /dev/null and b/docs/developer-guide/img/pulse-welcome.png differ diff --git a/docs/developer-guide/img/pulse-workouts.png b/docs/developer-guide/img/pulse-workouts.png new file mode 100644 index 00000000000..e54db3fb698 Binary files /dev/null and b/docs/developer-guide/img/pulse-workouts.png differ diff --git a/docs/website/content/pulse.md b/docs/website/content/pulse.md new file mode 100644 index 00000000000..224d9dc8e6e --- /dev/null +++ b/docs/website/content/pulse.md @@ -0,0 +1,62 @@ +--- +title: "Build a Gym and Fitness Studio App in Java with Pulse" +description: "Pulse is a fitness studio application: class booking with waitlists, family accounts, a rotating QR door pass and NFC fobs, memberships and billing, trainer and front-desk tools, an owner dashboard and a Java backend. Free MIT-0 source." +url: "/pulse/" +summary: "A complete full-stack gym and studio application you can brand and extend." +images: ["/developer-guide/img/pulse-member-home.png"] +ShowToc: true +--- + +Pulse is a complete, full-stack fitness studio application in Java. Members book classes for themselves and their children, open the door with a pass on their phone, pay for their membership and train with friends; trainers run their classes and follow heart rate zones live; the front desk checks people in and takes walk-ins; the owner reads the numbers and fills the quiet classes. Use it to build your own gym, studio or club app with Codename One. + +**[Generate your project](/initializr/)** · **[Read the user's guide](/developer-guide/pulse-users-guide/)** · **[Browse the source](https://github.com/codenameone/CodenameOne/tree/master/scripts/pulse)** + +In the Initializr, choose the **Pulse** fitness studio template, set your application name and package, and generate the project. The application source is licensed under [MIT-0](https://github.com/codenameone/CodenameOne/blob/master/scripts/pulse/LICENSE): you can modify it, use it commercially and keep your changes private without royalties. + +## What comes with the application? + +| Part | Included workflows | +| --- | --- | +| Member | Book classes or join the waitlist, book for children in the household, show a rotating QR door pass, join, change, freeze and cancel plans, declare away periods, see visits and workouts, add friends, give kudos and invite friends to a class | +| Trainer | See the week's classes and rosters, mark attendance, follow members' heart rate zones live in class, see their month (classes, check-ins, fill, attendance, regulars, kudos), offer and take shifts | +| Front desk and kiosk | Check people in by QR pass, NFC fob or name with a reason for every refusal, record guests, day passes and trials, run a door kiosk on a tablet | +| Owner | Monthly recurring revenue, utilisation, members at risk and under-filled classes; insights with busy hours, retention, membership growth, class fill and no-shows, plan mix and coach rankings; a searchable member list with each member's history; billing with failed charges and voids; plans, timetable, staff and campaigns | +| Watch | Next class with a countdown, the door code, points, level and streak, and the classes coming up | +| Java backend | Accounts and roles, households, bookings that never oversell, door passes, subscription billing with proration, freezes and a dunning ladder, automatic class-filling and we-miss-you nudges with quiet hours, live updates over WebSocket | +| Shared code | API contracts and data classes shared between the client and server | + +The account's roles decide which screens a person sees. The same client project targets iOS, Android, desktop and the web, and the server runs on SQLite, PostgreSQL, MySQL and MariaDB. + +![Pulse member home with points and badges](/developer-guide/img/pulse-member-home.png) +![Pulse class timetable](/developer-guide/img/pulse-timetable.png) +![Pulse live class with heart rate zones](/developer-guide/img/pulse-live.png) +![Pulse owner dashboard](/developer-guide/img/pulse-owner.png) +![Pulse insights with busy hours and membership](/developer-guide/img/pulse-insights.png) +![Pulse coaches ranked with fill, attendance and kudos](/developer-guide/img/pulse-insights-coaches.png) +![Pulse member page with visits a week and favourite classes](/developer-guide/img/pulse-member-detail.png) +![Pulse billing with collected, outstanding and failed](/developer-guide/img/pulse-billing.png) +![Pulse trainer home with the coach's level, streak, rank and badges](/developer-guide/img/pulse-trainer-home.png) +![Pulse coaches' leaderboard and the trainer's month](/developer-guide/img/pulse-trainer-month.png) +![Pulse trainer shifts with swaps](/developer-guide/img/pulse-trainer-shifts.png) +![Pulse accounts administration](/developer-guide/img/pulse-accounts.png) +![Pulse account with roles and moderation](/developer-guide/img/pulse-account.png) +![Pulse staff rota](/developer-guide/img/pulse-owner-shifts.png) +![Pulse owner dashboard on a desktop with the menu docked](/developer-guide/img/pulse-desktop-owner.png) +![Pulse members list and detail side by side on a desktop](/developer-guide/img/pulse-desktop-members.png) +![Pulse watch next class](/developer-guide/img/pulse-watch.png) +![Pulse watch week with level ring and streak](/developer-guide/img/pulse-watch-week.png) +![Pulse watch coaching page](/developer-guide/img/pulse-watch-coach.png) + +## From first run to customization + +1. **[Run the project](https://github.com/codenameone/CodenameOne/tree/master/scripts/pulse#running-it).** The development profile needs nothing installed: an in-memory database, a demo studio with two locations and a full timetable, demo accounts for every role and simulated cards. +2. **[Try every role](/developer-guide/pulse-users-guide/).** The user's guide walks through the member, trainer, front desk and owner screens with the demo accounts and test cards. +3. **[Customize and deploy](/developer-guide/pulse-implementors-guide/).** The implementor's guide covers booking, the door pass, billing, nudges, providers, the database and what a studio needs before launch. + +## What does a live studio still need? + +Pulse supplies the connected application and its source. Running a studio on it also requires your own hosting, a payment processor, waiver and consent text for your jurisdiction, door hardware integration and provider configuration. The [implementor's guide](/developer-guide/pulse-implementors-guide/) lists them. + +## Other full-stack samples + +[Savor](/savor/) is a food-delivery service, [Wayline](/wayline/) a ride-hailing service and [Bastion](/bastion/) a security-first bank, all on the same architecture. diff --git a/docs/website/layouts/partials/cn1-flagship-samples.html b/docs/website/layouts/partials/cn1-flagship-samples.html index 30a53cf1639..2e5b7cfec6e 100644 --- a/docs/website/layouts/partials/cn1-flagship-samples.html +++ b/docs/website/layouts/partials/cn1-flagship-samples.html @@ -36,6 +36,14 @@ "images" (slice "/developer-guide/img/skirmish-home.png" "/developer-guide/img/skirmish-battle.png" "/developer-guide/img/skirmish-finale.png") "guide" "/developer-guide/skirmish-implementors-guide/" "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/skirmish") + (dict + "name" "Pulse" + "kind" "Gym and fitness studio" + "url" "/pulse/" + "summary" "Member, trainer, front desk and owner apps with a Java backend: class booking and waitlists, a rotating QR door pass, memberships and billing, live heart rate zones and a watch companion." + "images" (slice "/developer-guide/img/pulse-member-home.png" "/developer-guide/img/pulse-live.png" "/developer-guide/img/pulse-owner.png") + "guide" "/developer-guide/pulse-users-guide/" + "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/pulse") (dict "name" "Bastion" "kind" "Banking and crypto exchange" diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 8900cd9fb05..9c4faeb71f8 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -2304,6 +2304,17 @@ public Map getCacheStatus(File cachedFile) throws IOExceptio * @return */ public boolean isModified(String id) { + return isModified(id, new java.util.HashSet()); + } + + // A style can derive from itself through one of its states + // (`X.pressed { cn1-derive: X; }`), or from a style that derives back + // from it; following derives without remembering the path then never + // ends. A selector already on the path adds nothing new. + private boolean isModified(String id, java.util.Set visiting) { + if (!visiting.add(id)) { + return false; + } if (selectorCacheStatus != null) { CacheStatus status = selectorCacheStatus.get(id); if (status == null) { @@ -2320,21 +2331,21 @@ public boolean isModified(String id) { String selectedDerive = el.getThemeDerive(el.getFlattenedSelectedStyle(), ""); String pressedDerive = el.getThemeDerive(el.getFlattenedPressedStyle(), ""); String disabledDerive = el.getThemeDerive(el.getFlattenedDisabledStyle(), ""); - if (derive != null && selectorCacheStatus.containsKey(derive) && isModified(derive)) { + if (derive != null && selectorCacheStatus.containsKey(derive) && isModified(derive, visiting)) { return true; } - if (unselectedDerive != null && selectorCacheStatus.containsKey(unselectedDerive) && isModified(unselectedDerive)) { + if (unselectedDerive != null && selectorCacheStatus.containsKey(unselectedDerive) && isModified(unselectedDerive, visiting)) { return true; } - if (selectedDerive != null && selectorCacheStatus.containsKey(selectedDerive) && isModified(selectedDerive)) { + if (selectedDerive != null && selectorCacheStatus.containsKey(selectedDerive) && isModified(selectedDerive, visiting)) { return true; } - if (pressedDerive != null && selectorCacheStatus.containsKey(pressedDerive) && isModified(pressedDerive)) { + if (pressedDerive != null && selectorCacheStatus.containsKey(pressedDerive) && isModified(pressedDerive, visiting)) { return true; } - if (disabledDerive != null && selectorCacheStatus.containsKey(disabledDerive) && isModified(disabledDerive)) { + if (disabledDerive != null && selectorCacheStatus.containsKey(disabledDerive) && isModified(disabledDerive, visiting)) { return true; } diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSDeriveCycleCacheTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSDeriveCycleCacheTest.java new file mode 100644 index 00000000000..f13c57d4c1f --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/CSSDeriveCycleCacheTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The incremental compile asks whether a style changed since the last build by +/// following its derives. A style whose state derives from the style itself -- +/// `Tile.pressed { cn1-derive: Tile; }` -- or two styles deriving from each +/// other used to send that walk round forever: a StackOverflowError, and only +/// on a rebuild, since a first build has no cache to compare with. +public class CSSDeriveCycleCacheTest { + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + @Test + void testStateDerivingFromItsOwnStyleIsUnchangedOnRebuild() throws Exception { + String css = "Tile { color: #111111; }" + + "Tile.pressed { cn1-derive: Tile; background-color: #eeeeee; }"; + assertFalse(rebuild(css, css, "Tile"), "nothing changed"); + } + + @Test + void testStylesDerivingFromEachOtherAreUnchangedOnRebuild() throws Exception { + String css = "Ping { color: #111111; }" + + "Ping.pressed { cn1-derive: Pong; }" + + "Pong { color: #222222; }" + + "Pong.pressed { cn1-derive: Ping; }"; + assertFalse(rebuild(css, css, "Ping"), "nothing changed"); + } + + @Test + void testChangeBehindACycleIsStillSeen() throws Exception { + String before = "Ping { color: #111111; }" + + "Ping.pressed { cn1-derive: Pong; }" + + "Pong { color: #222222; }" + + "Pong.pressed { cn1-derive: Ping; }"; + String after = before.replace("#222222", "#333333"); + assertTrue(rebuild(before, after, "Ping"), "Pong changed, and Ping derives from it"); + } + + // Saves the checksums of `before`, loads `after` against them, and asks + // whether `id` changed. + private static boolean rebuild(String before, String after, String id) throws Exception { + Path cssFile = Files.createTempFile("cn1-derive-cycle", ".css"); + Path cache = Files.createTempFile("cn1-derive-cycle", ".checksums"); + try { + Files.write(cssFile, before.getBytes(StandardCharsets.UTF_8)); + CSSTheme.load(cssFile.toUri().toURL()).saveSelectorChecksums(cache.toFile()); + Files.write(cssFile, after.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.getCacheStatus(cache.toFile()); + return theme.isModified(id); + } finally { + delete(cssFile.toFile()); + delete(cache.toFile()); + } + } + + private static void delete(File file) { + if (file.exists() && !file.delete()) { + file.deleteOnExit(); + } + } +} diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 3c28aabb62e..1a5d4049a0d 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -38,11 +38,11 @@ is_excluded() { local candidate="$1" # What Initializr puts into a generated project is the developer's, not # Codename One's GPLv2 + Classpath Exception source: these trees are released - # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE, + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE, scripts/pulse/LICENSE, # scripts/bastion/LICENSE, scripts/reel/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|scripts/pulse/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 3b589547bb3..7eaab8d3d93 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -468,7 +468,7 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o return empty; } Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, - Template.BASTION, Template.REEL}; + Template.BASTION, Template.REEL, Template.PULSE}; int offered = 0; for (Template app : apps) { if (GeneratorModel.isTemplateOffered(app)) { @@ -513,6 +513,7 @@ static String templateTitle(Template template) { : template == Template.PARLEY ? "Messaging" : template == Template.WAYLINE ? "Ride hailing" : template == Template.BASTION ? "Banking" : template == Template.REEL ? "Video streaming" + : template == Template.PULSE ? "Fitness studio" : template.IS_KOTLIN ? "Empty app, Kotlin" : "Empty app, Java"; } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 5212d3810f8..bbfcce06e57 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -124,6 +124,9 @@ static String templateSince(Template template) { /// The streaming template's `--brand`, held to its theme.css the same way. public static final int REEL_BRAND = 0xd93a42; + /// The fitness studio template's `--brand`, held to its theme.css the same way. + public static final int PULSE_BRAND = 0xff5a1f; + /// The brand colour `template` ships with: the one its icon is painted with /// when the developer keeps the template's own scheme. public static int brandOf(Template template) { @@ -133,6 +136,9 @@ public static int brandOf(Template template) { if (template == Template.REEL) { return REEL_BRAND; } + if (template == Template.PULSE) { + return PULSE_BRAND; + } if (template == Template.BASTION) { return BASTION_BRAND; } @@ -2071,9 +2077,13 @@ private String buildReadmeMarkdown() { boolean food = template == Template.SAVOR; boolean chat = template == Template.PARLEY; boolean video = template == Template.REEL; + boolean studio = template == Template.PULSE; String lower = toLowerCaseInvariant(appName); out.append("## The app and its server\n\n") - .append(video + .append(studio + ? "This project is a working fitness studio app: a member, a trainer, the front desk " + + "and an owner mode in one " + : video ? "This project is a working video streaming service: a viewer, a creator studio, " + "moderation and an admin console in one " : chat @@ -2086,7 +2096,11 @@ private String buildReadmeMarkdown() { .append("app, chosen by the role of whoever signs in, and the server they talk to.\n\n") .append("- `shared/` is the contract: the REST interfaces and the objects they carry. The app's ") .append("client and the server's dispatcher are both generated from it, so the two cannot drift.\n") - .append(video + .append(studio + ? "- `backend/` is the server: accounts and households, the timetable and bookings, " + + "door passes and check-in, memberships and billing, nudges, the live channel and " + + "the database migrations.\n" + : video ? "- `backend/` is the server: accounts and profiles, the catalog, playback and " + "licences, subscriptions and payments, uploads, moderation, the live channel and " + "the database migrations.\n" @@ -2111,7 +2125,12 @@ private String buildReadmeMarkdown() { .append("Bash or WSL.\n\n") .append("On the `dev` profile the server creates demonstration accounts, all with the ") .append("password `").append(lower).append("-demo`: ") - .append(video + .append(studio + ? "`member@" + lower + ".example`, who books for two children, `member2@" + lower + + ".example`, `trainer@" + lower + ".example`, `desk@" + lower + ".example`, who works " + + "the front desk and the door kiosk, `owner@" + lower + ".example`, who runs the " + + "studio, and `member3@" + lower + ".example`, whom the owner has flagged " + : video ? "`viewer@" + lower + ".example`, `viewer2@" + lower + ".example`, `creator@" + lower + ".example`, who uploads the catalog, `moderator@" + lower + ".example`, `admin@" + lower + ".example` and `viewer3@" + lower + ".example`, who has been flagged " @@ -2126,7 +2145,7 @@ private String buildReadmeMarkdown() { + ".example` and `applicant@" + lower + ".example`, who has applied to deliver " : "`rider@" + lower + ".example`, `driver@" + lower + ".example`, `admin@" + lower + ".example` and `applicant@" + lower + ".example`, who has applied to drive ") - .append(chat || video ? "for the admin to act on. " : "and waits for the admin's decision. ") + .append(studio ? "for the owner to act on. " : chat || video ? "for the admin to act on. " : "and waits for the admin's decision. ") .append("Verification codes are written to the server's log until an SMS provider is ") .append(chat ? "configured. Calls use a public STUN server until the server is given a TURN " @@ -2139,7 +2158,12 @@ private String buildReadmeMarkdown() { .append("serves it at `http://localhost:8080/`. In a desktop window or a desktop browser the ") .append("admin mode becomes a console with the operating system's own look.\n\n") .append("Two chapters of the developer guide, at https://www.codenameone.com/developer-guide/, ") - .append(chat + .append(studio + ? "cover the sample this project started as: \"A fitness studio app from every side\" " + + "shows it as a member, a trainer, the front desk and the owner see it, and " + + "\"Building on the sample\" explains how it is built and what to change: billing, " + + "the door, nudges and the database.\n\n" + : chat ? "cover the sample this project started as: \"A private messenger from three sides\" " + "shows it as two people chatting and an admin see it, and \"Building on the sample\" " + "explains how it is built and what to change: the encryption, calls and TURN, the " diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 4d401d00368..2e680aeeee6 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -97,7 +97,16 @@ public enum Template { "com.codenameone.examples.reel", "Reel", "/reel-preview.jpg", "The streaming template is an app, a server and the contract module they share; " + "an Initializr Gradle project is a single project.", - new String[0], "/reel-modules.zip", "/reel-settings.properties"); + new String[0], "/reel-modules.zip", "/reel-settings.properties"), + // The same kind of template as WAYLINE: a fitness studio (members, trainers, + // the front desk and the owner) and its server. It is the project in + // scripts/pulse, and its resources come from + // `scripts/sync-initializr-template.py pulse`. + PULSE(false, "/pulse-src.zip", "/pulse-css.zip", "/pulse-pom.xml", + "com.codenameone.examples.pulse", "Pulse", "/pulse-preview.jpg", + "The fitness studio template is an app, a server and the contract module they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/pulse-modules.zip", "/pulse-settings.properties"); /// The resource holding the picture shown for a template that has no live /// preview, or null for one that has. diff --git a/scripts/initializr/common/src/main/resources/pulse-css.zip b/scripts/initializr/common/src/main/resources/pulse-css.zip new file mode 100644 index 00000000000..c69cba37d04 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/pulse-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/pulse-modules.zip b/scripts/initializr/common/src/main/resources/pulse-modules.zip new file mode 100644 index 00000000000..5b55ab6f723 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/pulse-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/pulse-pom.xml b/scripts/initializr/common/src/main/resources/pulse-pom.xml new file mode 100644 index 00000000000..c86dfe1290a --- /dev/null +++ b/scripts/initializr/common/src/main/resources/pulse-pom.xml @@ -0,0 +1,406 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + xalan + serializer + 2.7.3 + + + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/pulse-preview.jpg b/scripts/initializr/common/src/main/resources/pulse-preview.jpg new file mode 100644 index 00000000000..72c3043e0e0 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/pulse-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/pulse-settings.properties b/scripts/initializr/common/src/main/resources/pulse-settings.properties new file mode 100644 index 00000000000..723c2208aef --- /dev/null +++ b/scripts/initializr/common/src/main/resources/pulse-settings.properties @@ -0,0 +1,25 @@ +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# What iOS shows when the front desk first scans a member's pass. +codename1.arg.ios.NSCameraUsageDescription=The camera scans members' passes at the front desk. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +codename1.arg.ios.locationUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +codename1.watchMain=com.codenameone.examples.pulse.watch.PulseWatch +codename1.arg.ios.NSHealthShareUsageDescription=Pulse reads your recent workouts so your week at the studio includes them. +codename1.arg.ios.NSHealthUpdateUsageDescription=Pulse saves the classes you record as workouts. +codename1.arg.ios.NSBluetoothAlwaysUsageDescription=Pulse connects to your heart rate strap during a class. +# Heart rate only: Android has no workout record here; a recorded session is saved +# as its heart rate samples, so a workout permission would never be used. +codename1.arg.android.health.read=heart_rate +codename1.arg.android.health.write=heart_rate +codename1.arg.android.health.privacyPolicyUrl=https\://www.codenameone.com/privacy-policy.html diff --git a/scripts/initializr/common/src/main/resources/pulse-src.zip b/scripts/initializr/common/src/main/resources/pulse-src.zip new file mode 100644 index 00000000000..55fd202b87d Binary files /dev/null and b/scripts/initializr/common/src/main/resources/pulse-src.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/references/demos.md b/scripts/initializr/common/src/main/resources/skill/references/demos.md index 42c4fd0d813..2eb637db1cd 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/demos.md +++ b/scripts/initializr/common/src/main/resources/skill/references/demos.md @@ -43,6 +43,7 @@ README of each lists the demo accounts. | `scripts/parley` | End-to-end encrypted messenger with calls | Chat UI and media (`chat/`, `ui/ChatForm.java`), crypto with core APIs (`crypto/`), WebRTC calls (`com.codename1.webrtc`, `ui/CallScreen.java`), local store (`store/`), push and WebSocket delivery | | `scripts/reel` | Video streaming: viewer, creator studio, moderation, admin | Video playback and its controls, audio and subtitle tracks (`player/`), protected (DRM) playback and offline downloads, uploads and transcoding jobs (`studio/`), plans, coupons and ads (`pay/`, `admin/`), households, profiles and parental ratings (`viewer/`) | | `scripts/bastion` | Security-hardened bank and crypto exchange: customer, staff console | Security end to end: sealed key storage, biometric/PIN app lock, certificate pinning, secure windows (`security/`), device-key request signing, passkeys, TOTP and step-up (`security/RequestSigner.java`, server `money/RequestSigning.java`, `account/`), a Security Center that reports each protection's live state (`ui/SecurityCenterForm.java`); a double-entry ledger with idempotency and conditional updates (server `ledger/`), a hash-chained audit log (`audit/`), accounting reports and PDF printing (`books/`, `ui/Printout.java`), an order-matching exchange (`exchange/`), and real Ether custody on an Ethereum node (server `chain/`). Its implementor's guide ends with a security checklist | +| `scripts/pulse` | Gym and studio: member, trainer, front desk, owner | Health and workouts (`com.codename1.health`), a watch companion (`wearable`), rotating QR and NFC door passes (`codescan`, `nfc`), class booking with waitlists and device calendar, households and kids' bookings, an owner dashboard with charts and a desktop master-detail layout, server-side billing with dunning and engagement nudges over push | | `scripts/skirmish` | Real-time 3D card battler | Games: `GameView`/3D rendering (`battle/`), a deterministic `rules` module shared by app, server and tests, replays, in-app purchases and rewarded ads (`engage/`), matchmaking over sockets, in-app editors (`forge/`) | ## Patterns the app demos share (copy these) diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 943d7ffc25c..a2968dc5770 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -378,6 +378,7 @@ private void assertFullStackProject(Template template, Map entri boolean bank = template == Template.BASTION; boolean game = template == Template.SKIRMISH; boolean video = template == Template.REEL; + boolean studio = template == Template.PULSE; String name = template.SOURCE_MAIN_CLASS; String upper = template.name(); String lower = GeneratorModel.toLowerCaseInvariant(name); @@ -392,12 +393,12 @@ private void assertFullStackProject(Template template, Map entri // project once got the ride-hailing accounts and chapters. String readme = getText(entries, "README.md"); boolean chat = template == Template.PARLEY; - assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : video ? "video streaming service" : "food-delivery app", + assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : studio ? "fitness studio app" : video ? "video streaming service" : "food-delivery app", label + "README names the app"); if (!game) { - assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : video ? "`viewer@" : "`customer@") + artifact + ".example`", + assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : studio ? "`member@" : video ? "`viewer@" : "`customer@") + artifact + ".example`", label + "README lists the template's own demo accounts"); - assertFalse(readme.indexOf(rides || chat || bank || video ? "courier@" : "rider@") >= 0, + assertFalse(readme.indexOf(rides || chat || bank || video || studio ? "courier@" : "rider@") >= 0, label + "README carries no other template's accounts"); assertFalse(chat && readme.indexOf("rider@") >= 0, label + "README carries no ride-hailing accounts"); } @@ -418,15 +419,15 @@ private void assertFullStackProject(Template template, Map entri assertContains(getText(entries, "backend/pom.xml"), "generate-annotation-stubs", label + "the server generates its half of the contract"); - assertContains(getText(entries, "shared/src/main/java/" + packagePath + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : bank ? "/api/BankingApi.java" : video ? "/api/CatalogApi.java" : "/api/OrderApi.java")), + assertContains(getText(entries, "shared/src/main/java/" + packagePath + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : bank ? "/api/BankingApi.java" : video ? "/api/CatalogApi.java" : studio ? "/api/BookingApi.java" : "/api/OrderApi.java")), "package " + packageName + ".api;", label + "the contract moves to the project's package"); assertContains(getText(entries, "backend/src/main/java/" + packagePath + "/SecurityConfig.java"), "package " + packageName + ";", label + "the server moves to the project's package"); - assertNotNull(entries.get("backend/src/main/resources/db/migration/" + (game ? "V1__game.sql" : bank ? "V4__accounts.sql" : "V1__accounts.sql")), + assertNotNull(entries.get("backend/src/main/resources/db/migration/" + (game ? "V1__game.sql" : bank ? "V4__accounts.sql" : studio ? "V2__accounts.sql" : "V1__accounts.sql")), label + "the server keeps its migrations"); - assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : bank ? "/LedgerTest.java" : video ? "/PlaybackTest.java" : "/OrderFlowTest.java")), + assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : bank ? "/LedgerTest.java" : video ? "/PlaybackTest.java" : studio ? "/StudioTest.java" : "/OrderFlowTest.java")), label + "the server keeps its tests"); - assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : bank ? "/ui/HomeForm.java" : video ? "/ui/WelcomeForm.java" : "/customer/CustomerForm.java")), + assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : bank ? "/ui/HomeForm.java" : video ? "/ui/WelcomeForm.java" : studio ? "/member/MemberForm.java" : "/customer/CustomerForm.java")), label + "the app's screens move to the project's package"); if (!game) { assertNotNull(entries.get("common/src/main/l10n/Bundle.properties"), label + "the app keeps its bundle"); @@ -451,6 +452,11 @@ private void assertFullStackProject(Template template, Map entri // from the library. assertContains(settings, "codename1.arg.ios.NSPhotoLibraryUsageDescription=", label + "the template's iOS photo library prompt"); + } else if (studio) { + // The studio asks for no location; it reads workouts from Health + // and scans the door pass with the camera. + assertContains(settings, "codename1.arg.ios.NSHealthShareUsageDescription=", + label + "the template's iOS Health prompt"); } else if (!game) { assertContains(settings, "codename1.arg.ios.NSLocationWhenInUseUsageDescription=", label + "the template's iOS location prompt"); diff --git a/scripts/initializr/e2e-shipped.sh b/scripts/initializr/e2e-shipped.sh index 4e2ff0ecbcb..a94e4ac7e41 100755 --- a/scripts/initializr/e2e-shipped.sh +++ b/scripts/initializr/e2e-shipped.sh @@ -12,7 +12,7 @@ # # e2e-shipped.sh [--require-latest] [--no-ui] [TEMPLATE ...] # -# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION or REEL; all of them +# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL or PULSE; all of them # by default. Every other check of the templates builds them against this # checkout's 8.0-SNAPSHOT, which no developer has: the generated pom names the # release the Initializr pins, and that is the only thing this script builds @@ -63,7 +63,7 @@ for arg in "$@"; do *) TEMPLATES+=("$arg") ;; esac done -[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION REEL) +[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION REEL PULSE) log() { echo "[e2e-shipped] $*" >&2; } fail() { echo "[e2e-shipped] FAIL: $*" >&2; exit 1; } diff --git a/scripts/pulse/LICENSE b/scripts/pulse/LICENSE new file mode 100644 index 00000000000..a805b496f43 --- /dev/null +++ b/scripts/pulse/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Pulse sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/pulse/README.adoc b/scripts/pulse/README.adoc new file mode 100644 index 00000000000..c5baf976ee2 --- /dev/null +++ b/scripts/pulse/README.adoc @@ -0,0 +1,192 @@ += Pulse + +A fitness studio's application and its server, in one Maven project and one +language. Members book classes for themselves and their children, open the door +with a pass on their phone or a fob, pay for their membership and keep up with +their friends; trainers run their classes and see who is in the zone; the front +desk lets people in and takes walk-ins; the owner reads the numbers and fills the +quiet classes. Which of these a person sees is decided by the roles the server +gives their account, and a tablet by the door runs the same app as a kiosk. + +It is a sample of how a Codename One application and a Codename One Backend fit +together, and of the health, wearable, NFC and code-scanning APIs a studio app +reaches for. + +== Start here + +Two chapters of the developer guide cover this project: + +* https://www.codenameone.com/developer-guide/#pulse-users-guide[The user's guide] + (link:../../docs/developer-guide/Pulse-Users-Guide.asciidoc[source]) shows the + application as a member, a trainer, the front desk and the owner see it, with + the demo accounts and test cards. +* https://www.codenameone.com/developer-guide/#pulse-implementors-guide[The implementor's guide] + (link:../../docs/developer-guide/Pulse-Implementors-Guide.asciidoc[source]) + explains how the project is built and what to change to make it yours: the + booking rule that never oversells a class, the door pass, billing and its + dunning ladder, the nudges, the providers and the database, and what a studio + needs before it runs on it. + +To see it running first, the two commands under <> are all it takes. + +== The modules + +[cols="1,4"] +|=== +| `shared` +| The contract: the data classes both sides exchange and the `@RestClient` + interfaces that name every call. The app's build generates a client from them + and the server's build generates the interfaces it implements, so a change the + other side did not follow is a compile error. + +| `common` +| The application: sign-in, the member, trainer, desk, kiosk and owner screens, + the door pass, the wallet, the settings and the live channel. Styled in + `src/main/css/theme.css`. The text is in `src/main/l10n`, in English, + Spanish, French, German and Hebrew; the translations are machine-generated, + so have them checked before you ship. What it reports to Codename One + analytics about how it is used, once the user has agreed, is all in + `Telemetry`. + +| `backend` +| The server: accounts and roles, phone verification, households and their + children, the timetable, bookings and the waitlist, door passes and + check-ins, walk-ins, trainers' rosters and shifts, live heart rates, + memberships and billing, away periods, nudges and campaigns, friends and + kudos, the owner's dashboard, and a WebSocket that pushes changes as they + happen. The data is entity classes in `domain`, read and written by the + repositories in `repository`. The schema is the migration scripts in + `src/main/resources/db/migration`, and the same server runs on SQLite, + PostgreSQL, MySQL and MariaDB. +|=== + +There are no platform modules: `common` builds the application for the +simulator, the desktop, Android, iOS and the browser itself. + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, text messages and push notifications are printed on the +console, and cards are simulated. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the application in the simulator, from another terminal: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The development profile creates a studio with two locations, a timetable of +adult and children's classes from three weeks back to three weeks ahead, the +plans on sale, and these accounts, all with the password `pulse-demo`: + +[cols="2,3"] +|=== +| `member@pulse.example` | a member with two children in their household and a card on file +| `member2@pulse.example` | a new member: no household, no card +| `member3@pulse.example` | a member the owner has flagged +| `member4@pulse.example` | a member who has not been in for weeks, for the we-miss-you nudge +| `trainer@pulse.example`, `trainer2@pulse.example` | teach classes +| `desk@pulse.example` | works the front desk and the door kiosk +| `owner@pulse.example` | runs the studio +|=== + +On a phone, set the server's address on the welcome screen: `localhost` on a +phone is the phone. + +=== In a browser, with nothing installed + +The server can host the application itself. Build the browser version once, and +again when the application changes: + +[source,bash] +---- +backend/server.sh web +CN1_PROFILE=dev backend/server.sh run +---- + +Then open `http://localhost:8080/`. `server.sh run --web` does both in one step. + +== What each role does + +A member books a place in a class, or joins its waitlist when it is full, and is +moved into the class the moment someone gives a place back. They book for their +children too: each child has their own membership, place and door pass, and the +classes offered are the ones their age fits on the day. The door pass is a QR +code that changes every 30 seconds and works with no signal, or an NFC fob for a +child without a phone. Members join, change, freeze and cancel plans, declare +when they will be away, add friends, give kudos and invite a friend to a class. +Workouts arrive from the phone's health store and the watch. + +A trainer sees the week's classes, who is booked, who is waiting and who came, +marks attendance the door missed, follows each member's heart rate zone live +during a class, and offers shifts to the other trainers or takes theirs. + +The front desk checks people in by scanning a pass, reading a fob or finding the +member by name, and every refusal comes with the reason to read out. It records +walk-ins: guests, day passes and free trials. A tablet signed in as the desk runs +as the kiosk by the door. + +The owner reads monthly recurring revenue, utilisation, the members at risk of +leaving and the classes still under half full, edits plans, the timetable and +the staff, and sends campaigns. + +== What is real and what is simulated + +Text messages go through the provider `pulse.sms.provider` names: `twilio`, +`vonage` or `messagebird`, each with its own credentials. On a development +profile they are printed on the console instead, and the app is handed the code +so a sign-up can finish. Any other profile needs a provider; one whose +credentials are missing is logged as a configuration error, naming the missing +settings. + +Payments are simulated on a development profile: the card +`4242 4242 4242 4242` is accepted, `4000 0000 0000 0002` is declined, and no +money moves. With `pulse.payments.provider=stripe` and +`PULSE_PAYMENTS_STRIPE_SECRET` set in the server's environment, a card is added +on Stripe's own page, so its number never reaches the app or this server. + +Push notifications go through the Codename One push service with +`pulse.push.provider=cloud`, `pulse.push.app` and `PULSE_PUSH_KEY`. + +`backend/application.properties` lists every setting a deployment supplies. The +database is the one named by `DATABASE_URL`: a `postgres://` or `mysql://` +address, or the path of a SQLite file. Every setting is also read from the +environment in upper case with `_` for every dot (`PULSE_SMS_TWILIO_TOKEN`). + +== Tests + +[source,bash] +---- +mvn -pl shared,backend -Dcodename1.platform=backend test # <1> +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # <2> +../ci/sample-server-tests-on-database.sh . # <3> +./run-e2e.sh # <4> +./run-web-e2e.sh # <5> +---- +<1> The server's tests, on the JVM. +<2> The same tests again, translated to a native binary, which is how the server + ships. +<3> The server's tests against PostgreSQL or MySQL, named by + `CN1_TEST_DATABASE_URL`. +<4> Starts the server on the `test` profile and runs the application's tests + against it in the simulator. Pass `--native` to run them against the + compiled server. +<5> Builds the browser version of the application, starts the server hosting + it, and drives it in a headless browser. Needs Node with Playwright and its + Chromium (`npm ci` and `npx playwright install chromium` in `scripts/`). + +`.github/workflows/pulse.yml` runs them, and builds the application for Android +and iOS through `scripts/build-android-app.sh` and `scripts/build-ios-app.sh` +with `CN1_APP_DIR=scripts/pulse`. + +== Licence + +Pulse is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. diff --git a/scripts/pulse/backend/application-dev.properties b/scripts/pulse/backend/application-dev.properties new file mode 100644 index 00000000000..79f766c15f3 --- /dev/null +++ b/scripts/pulse/backend/application-dev.properties @@ -0,0 +1,30 @@ +# The development profile, chosen with CN1_PROFILE=dev. +# +# An in-memory SQLite database, so the server starts with nothing installed: the +# migrations create the tables at start-up, and the demo accounts are added to +# them. Point this at a file to keep the data between runs: +# +# cn1.datasource.url=./target/app.db +# +# Production points cn1.datasource.url at the real database instead -- a +# postgres:// or mysql:// URL -- and the same code runs against it. +cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). + +# Text messages and push notifications are printed on the console and cards are +# simulated. All three are the defaults of a development profile anyway, and are +# spelled out to show where the choice is made. The console senders are refused +# outside a development profile. See application.properties for the real +# providers. +# tag::pulse-properties-dev-providers[] +pulse.sms.provider=console +pulse.payments.provider=simulated +pulse.push.provider=console +# end::pulse-properties-dev-providers[] diff --git a/scripts/pulse/backend/application-test.properties b/scripts/pulse/backend/application-test.properties new file mode 100644 index 00000000000..aa054d674ca --- /dev/null +++ b/scripts/pulse/backend/application-test.properties @@ -0,0 +1,20 @@ +# The test profile, chosen with CN1_PROFILE=test. It is what run-e2e.sh starts the +# server on for the app's end-to-end tests, and what @BackendTest runs the +# server's own tests on. +# +# Like `dev` -- an in-memory database, the demo accounts, text messages that are +# printed and not sent -- and one thing more: push notifications are recorded +# for the tests to read instead of printed. +cn1.datasource.url=:memory: + +# The tests sign several accounts in from one address, one after another, which +# is exactly what the sign-in rate limit is there to stop. +pulse.signin.per-minute=1000 + +# Codes printed on the console (and handed back to the test as demoCode), cards +# simulated: no test reaches an SMS provider or a payment processor. +pulse.sms.provider=console +pulse.payments.provider=simulated + +# Push notifications are kept for the tests to read, not sent. +pulse.push.provider=recording diff --git a/scripts/pulse/backend/application.properties b/scripts/pulse/backend/application.properties new file mode 100644 index 00000000000..3a003158e24 --- /dev/null +++ b/scripts/pulse/backend/application.properties @@ -0,0 +1,95 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL, which is what a platform sets +# for you), then application-.properties, then this file. +# +# It sits in the module root on purpose. This runtime has no classpath to load a +# resource from, so Config opens application*.properties as FILES relative to the +# working directory: the module directory under `cn1:backend`, and wherever the +# binary is started once it is packaged. +# +# Choose a profile with CN1_PROFILE. The one beside this file is `dev`: +# +# CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# +# A development profile (dev, test, local) needs nothing else: an in-memory +# database, a signing key made at start-up, text messages printed on the +# console, simulated cards, and a demo studio: its accounts, its timetable and a +# few weeks of bookings and visits behind them. A real +# deployment sets, here or in its environment: +# +# cn1.datasource.url the database +# cn1.security.authorizationserver.issuer the public address of this server +# cn1.security.authorizationserver.jwk.keys the key tokens are signed with +# pulse.admin.email, pulse.admin.password the first owner, who grants the rest +# pulse.sms.provider and its settings below +# pulse.payments.provider and its settings below +# pulse.push.app, pulse.push.key push notifications, below +# +# and may change the currency (pulse.currency, ISO 4217, USD unless set) and the +# studio's time zone (pulse.timezone, America/New_York unless set), which quiet +# hours and the day's timetable are counted in. +# +# Text messages. pulse.sms.provider names the provider verification codes are +# sent through, and each needs its own settings: +# +# twilio pulse.sms.twilio.sid, pulse.sms.twilio.token, +# pulse.sms.twilio.from +# vonage pulse.sms.vonage.key, pulse.sms.vonage.secret, +# pulse.sms.vonage.from +# messagebird pulse.sms.messagebird.key, pulse.sms.messagebird.originator +# console prints the code; development profiles only +# +# Unset, it is console on a development profile and twilio on any other. A +# provider whose settings are missing is a configuration error: it is logged at +# start-up and on every send, naming the missing settings, and the app is told +# the code could not be sent. Nothing falls back to printing codes in +# production. +# +# Payments. pulse.payments.provider is simulated or stripe; unset, it is +# simulated on a development profile and stripe on any other. Simulated cards: +# 4242 4242 4242 4242 is accepted, 4000 0000 0000 0002 is declined, and nothing +# is charged. Stripe is an untested stub over Stripe's REST API, a starting +# point to try against a test-mode key: cards are added on Stripe's own page, so +# a card number never reaches the app or this server, and memberships are +# charged when they start and renew. It needs the secret key, and without it every payment +# call fails with a configuration error in the log. Keep the key and the SMS +# credentials out of this file and out of the repository; set them in the +# environment of the deployment instead, where each setting is read in upper +# case with _ for every dot: +# +# PULSE_PAYMENTS_STRIPE_SECRET a restricted or secret key (sk_...) +# PULSE_SMS_TWILIO_TOKEN and so on +# +# Stripe's page sends the browser back to /pay/return on this server when it is +# done, so the server has to know the address it is reached at from a phone. It +# uses cn1.security.authorizationserver.issuer, which a deployment sets anyway; +# pulse.public.url overrides that when the two differ. +# +# Push notifications. pulse.push.provider is cloud, console or recording; +# unset, it is recording on the test profile, console on the other development +# profiles and cloud on any other. cloud sends through the Codename One push +# service, which delivers to Firebase, Apple, Huawei and the browsers for you, +# and needs two settings from the Push section of the Codename One console: +# +# pulse.push.app the app's push id +# pulse.push.key a server key with the push scope; put it in the +# environment as PULSE_PUSH_KEY, like the other secrets +# +# Without them the server still starts and serves the studio; the missing settings +# are logged at start-up and again for every notification that is not sent. + + +# ${DATABASE_URL} is resolved when the value is READ, so this line is fine in a +# checkout where that variable is unset: the dev profile overrides the key, and +# nothing reads this one. SQLite (a file path), postgres:// and mysql:// all work. +cn1.datasource.url=${DATABASE_URL} + +# The port. A platform that sets PORT wins over this, which is what lets the same +# build deploy anywhere. +cn1.server.port=8080 + +# Creates the tables the accounts, the registered app and the tokens are kept in. +# Read by the build, so it has to be here and not in a profile's file. +cn1.security.schema.enabled=true diff --git a/scripts/pulse/backend/pom.xml b/scripts/pulse/backend/pom.xml new file mode 100644 index 00000000000..54cee425647 --- /dev/null +++ b/scripts/pulse/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.pulse + pulse + 1.0-SNAPSHOT + + com.codenameone.examples.pulse + pulse-backend + 1.0-SNAPSHOT + jar + + pulse-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + pulse-shared + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/pulse/backend/server.sh b/scripts/pulse/backend/server.sh new file mode 100755 index 00000000000..0549c420fa8 --- /dev/null +++ b/scripts/pulse/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Pulse server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, a geocoder that knows ten places +# (`test`) and text messages that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Pulse +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[pulse-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/pulse-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/pulse-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Caller.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Caller.java new file mode 100644 index 00000000000..8bc02f9e6ba --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Caller.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Days.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Days.java new file mode 100644 index 00000000000..5551bcc31df --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Days.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +/// Days, counted in UTC. +/// +/// The charts and the earnings are by day, and a day has to mean the same on +/// SQLite, PostgreSQL and MySQL, whose date functions agree on nothing. So the +/// tables hold milliseconds since the epoch, and the arithmetic is done here. +/// UTC throughout: a service in one city would shift by that city's offset. +public final class Days { + public static final long MILLIS = 86400000L; + + private Days() { + } + + /// The number of the day `millis` falls in: whole days since the epoch. + public static long number(long millis) { + return millis / MILLIS; + } + + /// The first millisecond of the day `millis` falls in. + public static long start(long millis) { + return millis - millis % MILLIS; + } + + /// The stretch of time from `from` to `to` cut at each midnight: the + /// number of each day it touches followed by how many of its milliseconds + /// fall in that day, day after day. Empty when `to` is not after `from`. + /// + /// Time on line is credited to the days it was spent in. Credited whole + /// to the day it ended in, a courier on line across midnight had the + /// minutes before it counted as the next day's. + public static long[] split(long from, long to) { + if (to <= from) { + return new long[0]; + } + long[] out = new long[(int) (number(to - 1) - number(from) + 1) * 2]; + long at = from; + for (int iter = 0; iter < out.length; iter += 2) { + long end = Math.min(to, start(at) + MILLIS); + out[iter] = number(at); + out[iter + 1] = end - at; + at = end; + } + return out; + } + + /// The day `millis` falls in, as `yyyy-MM-dd`. + public static String iso(long millis) { + // Days since the epoch to a civil date, by the usual era arithmetic: + // shift the year to start in March so the leap day is its last day. + long z = number(millis) + 719468L; + long era = z / 146097L; + long dayOfEra = z - era * 146097L; + long yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36524L - dayOfEra / 146096L) + / 365L; + long dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L); + long shifted = (5L * dayOfYear + 2L) / 153L; + long day = dayOfYear - (153L * shifted + 2L) / 5L + 1L; + long month = shifted < 10L ? shifted + 3L : shifted - 9L; + long year = yearOfEra + era * 400L + (month <= 2L ? 1L : 0L); + StringBuilder out = new StringBuilder(10); + out.append(year).append('-'); + if (month < 10L) { + out.append('0'); + } + out.append(month).append('-'); + if (day < 10L) { + out.append('0'); + } + return out.append(day).toString(); + } + + /// Whether `text` is a date written `yyyy-MM-dd`, with a month and a day + /// that could exist. + public static boolean isIso(String text) { + if (text == null || text.length() != 10 || text.charAt(4) != '-' || text.charAt(7) != '-') { + return false; + } + for (int iter = 0; iter < 10; iter++) { + char c = text.charAt(iter); + if (iter != 4 && iter != 7 && (c < '0' || c > '9')) { + return false; + } + } + int year = 0; + for (int iter = 0; iter < 4; iter++) { + year = year * 10 + (text.charAt(iter) - '0'); + } + int month = (text.charAt(5) - '0') * 10 + (text.charAt(6) - '0'); + int day = (text.charAt(8) - '0') * 10 + (text.charAt(9) - '0'); + // The day is held to its own month: these strings are compared as + // text and never parsed, so a 31st of February accepted here would be + // stored, and approved, as somebody's date of birth. + return month >= 1 && month <= 12 && day >= 1 && day <= length(year, month); + } + + // How many days a month has, by the Gregorian rule for leap years. + private static int length(int year, int month) { + if (month == 2) { + boolean leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Ids.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Ids.java new file mode 100644 index 00000000000..1750cb45102 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Ids.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one delivery says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Roles.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Roles.java new file mode 100644 index 00000000000..9fb4c6821d1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Roles.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +/// The roles an account can hold. Every account is a member; teaching, working +/// the front desk and running the studio are granted on top of that, and the +/// app offers a mode for each role the account holds. +public final class Roles { + public static final String MEMBER = "MEMBER"; + /// Teaches classes: sees their rosters, marks attendance, swaps shifts. + public static final String TRAINER = "TRAINER"; + /// Works the front desk and the door kiosk: checks people in, takes walk-ins. + public static final String DESK = "DESK"; + /// Runs the studio: plans, timetable, staff, campaigns, accounts, numbers. + public static final String OWNER = "OWNER"; + + private Roles() { + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Rows.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Rows.java new file mode 100644 index 00000000000..63ddaf02072 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Rows.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reading the rows a repository answers for a select of several values, +/// `select c.account, max(c.at) ...`, which come back as `Object[]`. +/// +/// What a column holds is checked and never cast blindly: a count is a `Long` +/// on one database and an `Integer` on another. +public final class Rows { + private Rows() { + } + + /// A column as a whole number; 0 for null or anything that is not a + /// number. + public static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// A column holding a point in time as milliseconds since the epoch: the + /// `Instant` an entity's time field is read as, or 0 for null or anything + /// else. + public static long millis(Object value) { + return value instanceof Instant ? ((Instant) value).toEpochMilli() : 0L; + } + + /// A column as text; empty for null. + public static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + /// Rows of `{name, count}` as a map from the name to the count. + public static Map counts(List rows) { + Map out = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put(text(row[0]), Long.valueOf(number(row[1]))); + } + return out; + } + + /// A single number a repository answers, which is null for a sum or a + /// maximum of no rows. + public static long number(Long value) { + return value == null ? 0L : value.longValue(); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SecurityConfig.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SecurityConfig.java new file mode 100644 index 00000000000..e8a629dc923 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SecurityConfig.java @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are MEMBER, TRAINER, DESK and OWNER ([Roles]). `/api/trainer` is +/// for trainers, `/api/desk` for the front desk, `/api/owner` and `/api/admin` +/// for owners -- and an owner may do what a trainer or the desk does, since in +/// a small studio they cover both. The rest is for anyone signed in, and acts on +/// the caller's own account, household, bookings, cards and invoices. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "pulse-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:pulse:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("pulse").clientId(CLIENT_ID) + .clientName("Pulse") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${pulse.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `pulse.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an owner who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts and sending text messages are the two things + // here that cost something each time and that a stranger can ask + // for. One limiter per rule: a shared one would share its count. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + .rateLimit("/api/account/phone/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 600)) + // Guessing the current password with a token in hand, and filling + // the database with pictures: an hour's allowance of each is more + // than anyone honest needs. + .rateLimit("/api/account/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + // A pass code is six digits. The codes are single use and expire + // in a minute, but a door that answered as fast as it was asked + // would still be a guessing machine; a real queue at the door is a + // few people a minute. + .rateLimit("/api/desk/checkin", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(120, 60)) + // A friend request is a push to a stranger's phone. + .rateLimit("/api/social/friends", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 3600)) + // A watch sends a reading every few seconds through the class. + .rateLimit("/api/engagement/heartrate", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 60)) + // Every card setup is a session made at the payment processor. + // `Payments` caps how many an account has open; this caps how + // often it may start one, so abandoning setups in a loop is slow. + .rateLimit("/api/payments/methods/setup", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 3600)) + // tag::pulse-api-rules[] + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/trainer/**").hasAnyRole(Roles.TRAINER, Roles.OWNER) + .requestMatchers("/api/desk/**").hasAnyRole(Roles.DESK, Roles.OWNER) + .requestMatchers("/api/owner/**").hasRole(Roles.OWNER) + .requestMatchers("/api/admin/**").hasRole(Roles.OWNER) + .anyRequest().authenticated()) + // end::pulse-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Services.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Services.java new file mode 100644 index 00000000000..70855550159 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Services.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codenameone.examples.pulse.pay.MisconfiguredPayments; +import com.codenameone.examples.pulse.pay.PaymentProvider; +import com.codenameone.examples.pulse.pay.SimulatedPayments; +import com.codenameone.examples.pulse.pay.StripePayments; +import com.codenameone.examples.pulse.push.CloudPushSender; +import com.codenameone.examples.pulse.push.ConsolePushSender; +import com.codenameone.examples.pulse.push.MisconfiguredPushSender; +import com.codenameone.examples.pulse.push.PushSender; +import com.codenameone.examples.pulse.push.RecordingPushSender; +import com.codenameone.examples.pulse.sms.ConsoleSmsSender; +import com.codenameone.examples.pulse.sms.MessageBirdSmsSender; +import com.codenameone.examples.pulse.sms.MisconfiguredSmsSender; +import com.codenameone.examples.pulse.sms.SmsSender; +import com.codenameone.examples.pulse.sms.TwilioSmsSender; +import com.codenameone.examples.pulse.sms.VonageSmsSender; + +import java.io.IOException; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. +/// +/// Each is named by one setting -- `pulse.sms.provider`, +/// `pulse.payments.provider` and `pulse.push.provider` -- and, when that is +/// not set, a development profile (dev, test, local) gets the one that needs no +/// account: codes and notifications printed on the console, cards simulated. Any other profile gets the real provider, +/// and when its settings are missing the server still starts, logs a +/// configuration error naming each missing setting, and fails every call to +/// that provider with the same message. It never quietly falls back to a +/// development stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`PULSE_SMS_TWILIO_TOKEN`), which is where secrets belong. +@Configuration +public class Services { + /// `pulse.sms.provider` is one of: + /// + /// - `twilio`: `pulse.sms.twilio.sid`, `.token` and `.from` + /// - `vonage`: `pulse.sms.vonage.key`, `.secret` and `.from` + /// - `messagebird`: `pulse.sms.messagebird.key` and `.originator` + /// - `console`: prints the message, on a development profile only + /// + /// Unset, it is `console` on a development profile and `twilio` otherwise. + @Bean + public SmsSender smsSender(Config config, + @Value("${pulse.sms.provider:}") String chosen, + @Value("${pulse.sms.twilio.sid:}") String twilioSid, + @Value("${pulse.sms.twilio.token:}") String twilioToken, + @Value("${pulse.sms.twilio.from:}") String twilioFrom, + @Value("${pulse.sms.vonage.key:}") String vonageKey, + @Value("${pulse.sms.vonage.secret:}") String vonageSecret, + @Value("${pulse.sms.vonage.from:}") String vonageFrom, + @Value("${pulse.sms.messagebird.key:}") String birdKey, + @Value("${pulse.sms.messagebird.originator:}") String birdOriginator) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "console" : "twilio"; + if ("console".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredSmsSender("pulse.sms.provider=console only prints codes " + + "and is refused on the '" + config.getProfile() + "' profile; set it to " + + "twilio, vonage or messagebird"); + } + return new ConsoleSmsSender(); + } + if ("twilio".equalsIgnoreCase(kind)) { + String missing = missing("pulse.sms.twilio.", new String[] {"sid", twilioSid, + "token", twilioToken, "from", twilioFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "twilio", + missing)) : new TwilioSmsSender(twilioSid, twilioToken, twilioFrom); + } + // tag::pulse-sms-choice[] + if ("vonage".equalsIgnoreCase(kind)) { + String missing = missing("pulse.sms.vonage.", new String[] {"key", vonageKey, + "secret", vonageSecret, "from", vonageFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "vonage", + missing)) : new VonageSmsSender(vonageKey, vonageSecret, vonageFrom); + } + // end::pulse-sms-choice[] + if ("messagebird".equalsIgnoreCase(kind)) { + String missing = missing("pulse.sms.messagebird.", new String[] {"key", birdKey, + "originator", birdOriginator}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "messagebird", + missing)) : new MessageBirdSmsSender(birdKey, birdOriginator); + } + return new MisconfiguredSmsSender("pulse.sms.provider is '" + kind + "', which is " + + "none of twilio, vonage, messagebird or console"); + } + + // tag::pulse-payment-choice[] + /// `pulse.payments.provider` is one of: + /// + /// - `stripe`: [StripePayments], an untested stub over Stripe's REST API. + /// It needs `pulse.payments.stripe.secret`. + /// - `simulated`: test card numbers, and no money moves. + /// + /// Unset, it is `simulated` on a development profile and `stripe` otherwise. + /// + /// With Stripe a card is typed into Stripe's own page, which then sends the + /// browser back to `/pay/return` on this server. That address has to be the + /// one a phone can reach, so it is `pulse.public.url` when that is set + /// and the issuer -- the public address of this server, which a deployment + /// sets anyway -- when it is not. + @Bean + public PaymentProvider paymentProvider(Config config, + @Value("${pulse.payments.provider:}") String chosen, + @Value("${pulse.payments.stripe.secret:}") String secret, + @Value("${pulse.public.url:}") String publicUrl, + @Value("${" + SecurityConfig.ISSUER_KEY + ":}") String issuer) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "stripe"; + if ("simulated".equalsIgnoreCase(kind)) { + return new SimulatedPayments(); + } + if (!"stripe".equalsIgnoreCase(kind)) { + return new MisconfiguredPayments(kind, "pulse.payments.provider is '" + kind + + "', which is neither stripe nor simulated"); + } + String address = publicUrl.length() > 0 ? publicUrl : issuer; + String missing = missing("", new String[] {"pulse.payments.stripe.secret", secret, + "pulse.public.url (or " + SecurityConfig.ISSUER_KEY + ")", address}); + if (missing.length() > 0) { + return new MisconfiguredPayments("stripe", unset("payments", "stripe", missing)); + } + while (address.endsWith("/")) { + address = address.substring(0, address.length() - 1); + } + return new StripePayments(secret, address + "/pay/return"); + } + // end::pulse-payment-choice[] + + // tag::pulse-push-choice[] + /// `pulse.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `pulse.push.app`, + /// the app's push id, and `pulse.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves deliveries: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${pulse.push.provider:}") String chosen, + @Value("${pulse.push.app:}") String app, + @Value("${pulse.push.key:}") String key, + @Value("${pulse.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("pulse.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("pulse.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("pulse.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::pulse-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SignInCodePage.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SignInCodePage.java new file mode 100644 index 00000000000..a121575cec4 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Text.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Text.java new file mode 100644 index 00000000000..b484a525cee --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/Text.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Courier" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountCleanup.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountCleanup.java new file mode 100644 index 00000000000..7e28a5fccfa --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountCleanup.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.repository.AwayPeriodRepository; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.FriendRepository; +import com.codenameone.examples.pulse.repository.KudosRepository; +import com.codenameone.examples.pulse.repository.LiveTicketRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.repository.PassKeyRepository; +import com.codenameone.examples.pulse.repository.PhoneCodeRepository; +import com.codenameone.examples.pulse.repository.PreferenceRepository; +import com.codenameone.examples.pulse.repository.PushDeviceRepository; +import com.codenameone.examples.pulse.repository.ShiftRepository; +import com.codenameone.examples.pulse.repository.WorkoutRepository; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// What closing an account does to the rows other parts of the server keep +/// under its name. +/// +/// These are statements over whole sets of rows -- everything of one account +/// in a table -- and not the work of the services that own those tables, +/// which is why they are gathered here. Each deletion is a bulk statement, so +/// whatever the session had loaded before it is stale afterwards. It has no +/// transaction of its own: [Accounts] calls it inside one. +/// +/// Invoices and check-ins stay: they are the studio's records of money taken +/// and visits made, and are kept under the closed account's name. +@Component +public class AccountCleanup { + private final MemberRepository members; + private final BookingRepository bookings; + private final ClassSessionRepository sessions; + private final MembershipRepository memberships; + private final PassKeyRepository passes; + private final WorkoutRepository workouts; + private final KudosRepository kudos; + private final FriendRepository friends; + private final NudgeRepository nudges; + private final AwayPeriodRepository away; + private final ShiftRepository shifts; + private final PreferenceRepository preferences; + private final PhoneCodeRepository codes; + private final LiveTicketRepository tickets; + private final PushDeviceRepository pushDevices; + + public AccountCleanup(MemberRepository members, BookingRepository bookings, + ClassSessionRepository sessions, MembershipRepository memberships, + PassKeyRepository passes, WorkoutRepository workouts, KudosRepository kudos, + FriendRepository friends, NudgeRepository nudges, AwayPeriodRepository away, + ShiftRepository shifts, PreferenceRepository preferences, PhoneCodeRepository codes, + LiveTicketRepository tickets, PushDeviceRepository pushDevices) { + this.members = members; + this.bookings = bookings; + this.sessions = sessions; + this.memberships = memberships; + this.passes = passes; + this.workouts = workouts; + this.kudos = kudos; + this.friends = friends; + this.nudges = nudges; + this.away = away; + this.shifts = shifts; + this.preferences = preferences; + this.codes = codes; + this.tickets = tickets; + this.pushDevices = pushDevices; + } + + /// Removes everything an account keeps, and hands back the places its + /// household held in classes still to come. Returns the ids of the + /// classes that now have a place free, so the caller can offer each to + /// the waitlist. + public List removeEverythingOf(String username) { + Instant now = Instant.now(); + List freed = new ArrayList(); + List held = bookings.findByAccountAndStateIn(username, + Arrays.asList("BOOKED", "WAITLISTED")); + for (int iter = 0; iter < held.size(); iter++) { + Booking booking = held.get(iter); + ClassSession session = sessions.findById(booking.getSessionId()); + if (session == null || !session.getStartsAt().isAfter(now)) { + continue; + } + if ("BOOKED".equals(booking.getState())) { + sessions.givePlace(session.getId()); + if (!freed.contains(session.getId())) { + freed.add(session.getId()); + } + } else { + sessions.moveQueue(session.getId(), -1); + } + } + bookings.deleteByAccount(username); + List household = members.findByAccountOrderByCreatedAtAsc(username); + List ids = new ArrayList(); + for (int iter = 0; iter < household.size(); iter++) { + ids.add(household.get(iter).getId()); + } + if (!ids.isEmpty()) { + passes.deleteByMemberIdIn(ids); + } + // A membership is ended rather than deleted: its invoices name it. + List plans = memberships.findByAccountOrderByStartedAtDesc(username); + for (int iter = 0; iter < plans.size(); iter++) { + Membership membership = plans.get(iter); + if (!"CANCELLED".equals(membership.getState())) { + membership.setState("CANCELLED"); + membership.setUpdatedAt(now); + } + } + members.deleteByAccount(username); + workouts.deleteByAccount(username); + kudos.deleteByFromAccount(username); + kudos.deleteByToAccount(username); + friends.deleteByAccount(username); + friends.deleteByFriend(username); + nudges.deleteByAccount(username); + away.deleteByAccount(username); + shifts.deleteByStaff(username); + preferences.deleteByUsername(username); + codes.deleteByUsername(username); + tickets.deleteByUsername(username); + pushDevices.deleteByUsername(username); + return freed; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountEndpoint.java new file mode 100644 index 00000000000..b6249f97ef0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/AccountEndpoint.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.AccountApiServer; +import com.codenameone.examples.pulse.api.AccountClosureDto; +import com.codenameone.examples.pulse.api.PasswordChangeDto; +import com.codenameone.examples.pulse.api.PhoneChallengeDto; +import com.codenameone.examples.pulse.api.PhoneCodeDto; +import com.codenameone.examples.pulse.api.PreferencesDto; +import com.codenameone.examples.pulse.api.ProfileDto; +import com.codenameone.examples.pulse.api.PushDeviceDto; +import com.codenameone.examples.pulse.api.RegisterDto; +import com.codenameone.examples.pulse.api.TicketDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.live.LiveTickets; +import com.codenameone.examples.pulse.push.PushDevices; + +/// The server's half of `AccountApi`. +/// +/// `AccountApiServer` is generated from the contract the app calls through, and +/// the build routes each of the contract's paths to this class. Change a path or +/// a type in the contract and both sides stop compiling until they agree. +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final PhoneVerification phones; + private final LiveTickets tickets; + private final Preferences preferences; + private final PushDevices devices; + + public AccountEndpoint(Accounts accounts, PhoneVerification phones, LiveTickets tickets, + Preferences preferences, PushDevices devices) { + this.accounts = accounts; + this.phones = phones; + this.tickets = tickets; + this.preferences = preferences; + this.devices = devices; + } + + @Override + public UserDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public UserDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public PhoneChallengeDto startPhoneVerification() throws Exception { + return phones.start(Caller.name()); + } + + @Override + public UserDto verifyPhone(PhoneCodeDto code) throws Exception { + return phones.verify(Caller.name(), code == null ? null : code.code()); + } + + @Override + public TicketDto liveTicket() throws Exception { + return tickets.issue(Caller.name()); + } + + @Override + public PreferencesDto preferences() throws Exception { + return preferences.get(Caller.name()); + } + + @Override + public PreferencesDto savePreferences(PreferencesDto wanted) throws Exception { + return preferences.save(Caller.name(), wanted); + } + + @Override + public ProfileDto profile() throws Exception { + return accounts.profile(Caller.name()); + } + + @Override + public UserDto saveProfile(ProfileDto profile) throws Exception { + return accounts.saveProfile(Caller.name(), profile); + } + + @Override + public UserDto changePassword(PasswordChangeDto change) throws Exception { + return accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + } + + @Override + public UserDto deleteAccount(AccountClosureDto closure) throws Exception { + return accounts.close(Caller.name(), closure == null ? null : closure.password()); + } + + @Override + public PushDeviceDto registerPush(PushDeviceDto device) throws Exception { + return devices.register(Caller.name(), device); + } + + @Override + public PushDeviceDto unregisterPush(PushDeviceDto device) throws Exception { + return devices.unregister(Caller.name(), device); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Accounts.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Accounts.java new file mode 100644 index 00000000000..ccebafd8389 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Accounts.java @@ -0,0 +1,706 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.Roles; +import com.codenameone.examples.pulse.Text; +import com.codenameone.examples.pulse.api.ProfileDto; +import com.codenameone.examples.pulse.api.RegisterDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.domain.ClosedAccount; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.pay.Payments; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClosedAccountRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/// Accounts: opening one, describing one, and what an owner may change. +/// +/// An account is two things kept under one name. Its sign-in -- password hash, +/// roles, whether it is enabled -- is the backend's own user store. Its profile +/// -- display name, phone, who referred it -- is this application's table. The +/// name is the e-mail address, in lower case. +/// +/// An account is a household. Opening one also creates its holder's own +/// member record, the one that is booked into classes and walks through the +/// door; children are added to it afterwards ([com.codenameone.examples.pulse.studio.Household]). +/// Everyone opens as a member. The staff roles -- trainer, front desk, owner -- +/// are given by an owner. +@Component +public class Accounts { + /// The youngest age at which someone may hold an account of their own. + /// Younger people are members of a parent's household. + public static final int ACCOUNT_AGE = 16; + + private final ProfileRepository profiles; + private final AccountCleanup cleanup; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final Moderation moderation; + private final Payments payments; + private final LiveHub live; + private final ClosedAccountRepository closed; + private final Grants grants; + private final MemberRepository members; + private final StaffRepository staffRows; + private final ClassSessionRepository sessions; + + public Accounts(ProfileRepository profiles, AccountCleanup cleanup, + JdbcUserDetailsManager users, PasswordEncoder encoder, Moderation moderation, + Payments payments, LiveHub live, ClosedAccountRepository closed, Grants grants, + MemberRepository members, StaffRepository staffRows, + ClassSessionRepository sessions) { + this.closed = closed; + this.grants = grants; + this.profiles = profiles; + this.cleanup = cleanup; + this.users = users; + this.encoder = encoder; + this.moderation = moderation; + this.payments = payments; + this.live = live; + this.members = members; + this.staffRows = staffRows; + this.sessions = sessions; + } + + /// Opens an account. Answers 400 for a request that cannot be an account + /// and 409 when the e-mail already has one. + /// + /// Not a transaction itself, so that it can look again after one failed. + /// Two requests for the same e-mail can both find it free; the name's + /// unique key then refuses the second, and what the database says about + /// that differs with the database. So nothing here reads the error: if the + /// account exists once the transaction is over, that is why it failed, and + /// the answer is the same 409 the check gives. + public UserDto register(RegisterDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException failed) { + throw taken(request, failed); + } catch (RuntimeException failed) { + throw taken(request, failed); + } + } + + /// The 409 for a registration that failed because the account was opened + /// meanwhile, or the failure itself when it was something else. + private T taken(RegisterDto request, T failed) { + if (request != null && request.email() != null + && users.userExists(email(request.email()))) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + return failed; + } + + @Transactional(rollbackFor = IOException.class) + private UserDto open(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String displayName = required(request.displayName(), 120, "your name"); + String phone = phone(request.phone()); + LocalDate born = birthDate(request.birthDate()); + if (age(born, LocalDate.now()) < ACCOUNT_AGE) { + throw new ResponseStatusException(400, "Under " + ACCOUNT_AGE + + "s join as part of a parent's household"); + } + // A referral names an existing member; anything else is dropped rather + // than refused, since it only decides who is thanked. + String referrer = ""; + if (request.referredBy() != null && request.referredBy().trim().length() > 0) { + String named = email(request.referredBy()); + if (users.userExists(named)) { + referrer = named; + } + } + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // An address whose account was closed is not given to anyone again: + // the invoices and visits that account had are still filed under it. + // See [#retire]. + if (closed.existsById(Ids.sha256(username))) { + throw new ResponseStatusException(409, + "That e-mail belonged to an account that was closed. Use another"); + } + create(username, request.password(), displayName, phone, false, born, false, false, + false, System.currentTimeMillis()); + if (referrer.length() > 0) { + profiles.findById(username).setReferredBy(referrer); + } + return describe(username); + } + + /// Creates the sign-in, the profile and the holder's own member record. + /// The caller has checked the name is free. An account created with staff + /// roles is how the demo accounts are made, not how anyone signs up. + /// `openedAt` is when the account was opened; the demo data is the one + /// caller with a past to write. + @Transactional(rollbackFor = IOException.class) + public void create(String username, String password, String displayName, String phone, + boolean phoneVerified, LocalDate born, boolean trainer, boolean desk, boolean owner, + long openedAt) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(roles(trainer, desk, owner)).build()); + Instant opened = Instant.ofEpochMilli(openedAt); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(displayName); + profile.setPhone(phone); + profile.setPhoneVerified(phoneVerified); + profile.setCreatedAt(opened); + profiles.insert(profile); + Member self = new Member(); + self.setId(Ids.next()); + self.setAccount(username); + self.setName(displayName); + self.setBirthDate(born); + self.setRelation(SELF); + self.setCreatedAt(opened); + members.insert(self); + if (trainer) { + staffRow(username, true); + } + } + + /// The relation of an account holder's own member record. + public static final String SELF = "SELF"; + + public boolean exists(String username) { + return users.userExists(username); + } + + /// Whether the account exists and has not been blocked, read once. + public boolean enabled(String username) { + try { + return users.loadUserByUsername(username).isEnabled(); + } catch (UsernameNotFoundException gone) { + return false; + } + } + + /// The account as the app sees it. Answers 404 for a name with no account. + @Transactional(readOnly = true) + public UserDto describe(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + // A member the desk signed up who has never signed in to the app has a + // profile and no sign-in: described as [#all()] lists them, with no + // roles, rather than as an account that does not exist. + if (!users.userExists(username)) { + return toDto(profile, new ArrayList(), true); + } + UserDetails signIn = users.loadUserByUsername(username); + return toDto(profile, authorities(signIn), signIn.isEnabled()); + } + + /// Every account, newest first, for the owner. + @Transactional(readOnly = true) + public List all() throws IOException { + List out = new ArrayList(); + List everyone = profiles.newestFirst(); + for (int iter = 0; iter < everyone.size(); iter++) { + Profile profile = everyone.get(iter); + // One sign-in read per account. The user store answers for one name + // at a time and has no way to list its users or to load several at + // once, and its tables are its own, not this application's to + // query. + List held = new ArrayList(); + boolean enabled = true; + try { + UserDetails signIn = users.loadUserByUsername(profile.getUsername()); + held = authorities(signIn); + enabled = signIn.isEnabled(); + } catch (UsernameNotFoundException gone) { + // A profile whose sign-in was deleted under it is listed with + // no roles rather than failing the whole list. + enabled = true; + } + out.add(toDto(profile, held, enabled)); + } + return out; + } + + /// Gives or withdraws the staff roles. `by` is the owner doing it, who + /// cannot withdraw their own owner role: that is how a studio ends up with + /// nobody able to run it. + @Transactional(rollbackFor = IOException.class) + public UserDto setRoles(String by, String username, boolean trainer, boolean desk, + boolean owner) throws IOException { + actingOwner(by, username); + UserDetails signIn = load(username); + if (!owner && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot withdraw your own owner role"); + } + List held = authorities(signIn); + boolean wasTrainer = held.contains("ROLE_" + Roles.TRAINER); + // Not from under a timetable. A class with nobody to teach it would be + // booked, paid for and turned up to; the owner gives its classes to + // someone else first. + if (wasTrainer && !trainer && sessions.existsByTrainerAndStateAndStartsAtGreaterThan( + username, "SCHEDULED", Instant.now())) { + throw new ResponseStatusException(409, + "That trainer has classes coming up. Give them to someone else first"); + } + users.updateUser(User.withUserDetails(signIn).roles(roles(trainer, desk, owner)) + .build()); + if (trainer != wasTrainer) { + staffRow(username, trainer); + } + moderation.record(username, by, Moderation.ROLES, describeRoles(trainer, desk, owner)); + UserDto now = describe(username); + boolean wasStaff = wasTrainer || held.contains("ROLE_" + Roles.DESK) + || held.contains("ROLE_" + Roles.OWNER); + if (wasStaff && !(trainer || desk || owner)) { + // A live connection opened as staff goes on hearing who came + // through the door; it is closed, and the app opens a member's. + live.drop(username); + } + return now; + } + + private static String describeRoles(boolean trainer, boolean desk, boolean owner) { + StringBuilder out = new StringBuilder("member"); + if (trainer) { + out.append(", trainer"); + } + if (desk) { + out.append(", front desk"); + } + if (owner) { + out.append(", owner"); + } + return out.toString(); + } + + /// Makes a trainer's staff row exist and be active, or retires it. The row + /// stays when the role goes, so the classes they taught still name them. + private void staffRow(String username, boolean active) { + Staff row = staffRows.findById(username); + if (row == null) { + if (!active) { + return; + } + row = new Staff(); + row.setUsername(username); + row.setTitle("Trainer"); + row.setActive(true); + staffRows.insert(row); + return; + } + row.setActive(active); + } + + /// Blocks an account or lifts the block. A blocked account cannot sign in, + /// and a token it already holds stops working at once, because the API + /// reads the account on every request. Its bookings stay: the desk can + /// still see who was expected, and the door refuses a blocked account by + /// itself. + @Transactional(rollbackFor = IOException.class) + public UserDto suspend(String by, String username, boolean suspended, String reason) + throws IOException { + actingOwner(by, username); + UserDetails signIn = load(username); + if (suspended && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot suspend your own account"); + } + String why = Text.optional(reason, 255, "the reason"); + users.updateUser(User.withUserDetails(signIn).disabled(suspended).build()); + if (suspended == signIn.isEnabled()) { + // Only a change is an event; saying the same thing twice is not. + moderation.record(username, by, suspended ? Moderation.BLOCK : Moderation.UNBLOCK, why); + } + return describe(username); + } + + /// Marks an account for the owners' attention, or clears the mark. A flag + /// changes nothing the account can do; blocking is what does that. + @Transactional(rollbackFor = IOException.class) + public UserDto flag(String by, String username, boolean flagged, String reason) + throws IOException { + // The profile row first, as the other owner changes hold it: a flag + // racing the account's closure either lands before erase() (which + // then forgets it with the rest) or finds no account. Otherwise the + // moderation row recorded below would outlive the closure's cleanup. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + load(username); + String why = Text.optional(reason, 255, "the reason"); + if (flagged && why.length() == 0) { + throw new ResponseStatusException(400, "Say why the account is flagged"); + } + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setFlagged(flagged); + profile.setFlagReason(flagged ? why : ""); + } + moderation.record(username, by, flagged ? Moderation.FLAG : Moderation.UNFLAG, why); + return describe(username); + } + + // ------------------------------------------------------------ one's own + + @Transactional(readOnly = true) + public ProfileDto profile(String username) throws IOException { + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + return new ProfileDto(row.getDisplayName()); + } + + @Transactional(rollbackFor = IOException.class) + public UserDto saveProfile(String username, ProfileDto profile) throws IOException { + if (profile == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String name = required(profile.name(), 120, "your name"); + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + row.setDisplayName(name); + return describe(username); + } + + /// Changes a password, for someone who knows the one it replaces. A token + /// in hand is not enough: it is what a borrowed phone has. The account's + /// other sign-ins end with the old password. + @Transactional(rollbackFor = IOException.class) + public UserDto changePassword(String username, String current, String replacement) + throws IOException { + // Before the password is read. Two changes at once would otherwise + // both check the same old password and both succeed, the last one + // winning: whoever else knows the old password could race the owner's + // change and keep the account. Behind the hold the second change + // checks against the password the first one set. + profiles.hold(username); + UserDetails signIn = load(username); + if (current == null || !encoder.matches(current, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + password(replacement); + users.updatePassword(signIn, encoder.encode(replacement)); + // A password is changed, as often as not, because someone else may + // have the old one or a device that was signed in with it. A refresh + // token asks for no password, so every sign-in but this one ends here. + grants.endOthers(username); + return describe(username); + } + + /// Closes an account, for good: the sign-in, the profile, the household, + /// its passes, saved cards and workouts are deleted, not hidden. Invoices and + /// visits stay, as the studio's records, under a name that no longer has + /// anyone behind it. Places held in classes to come are handed back, and + /// the waitlists move up on [com.codenameone.examples.pulse.studio.Bookings]' + /// next sweep. + /// + /// Refused for an owner, so that a studio is not left with none. + /// + /// `password` is the account's own, asked for again as a change of + /// password asks for it. A token alone is not enough for something that + /// cannot be undone: a phone left unlocked, or a token that leaked, would + /// otherwise be all it takes to destroy an account. + public UserDto close(String username, String password) throws IOException { + UserDto was = erase(username, password); + // After the commit and not inside it: what is said over a live + // connection is acted on at once, and must not be said about a change + // that could still be rolled back. + live.drop(username); + return new UserDto(was.username(), was.displayName(), was.phone(), was.phoneVerified(), + was.member(), was.trainer(), was.desk(), was.owner(), true, was.flagged(), + was.flagReason(), was.language()); + } + + /// Everything closing an account deletes, as one transaction: either the + /// account is gone from every table or it is in all of them still. The + /// user store works on the same database and so takes part in it. + @Transactional(rollbackFor = IOException.class) + UserDto erase(String username, String password) throws IOException { + // Before anything is read: a booking or a payment for the account + // holds the same row, so it either lands before the clean-up, which + // then removes it, or finds no account. + profiles.hold(username); + UserDetails signIn = load(username); + if (password == null || !encoder.matches(password, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + UserDto was = describe(username); + if (was.owner()) { + throw new ResponseStatusException(409, "Hand the owner role on before closing"); + } + // A charge the processor may still be making would be lost track of + // with the card it was made on. + payments.forget(username); + moderation.forget(username); + cleanup.removeEverythingOf(username); + if (was.trainer()) { + Staff staff = staffRows.findById(username); + if (staff != null) { + staffRows.deleteById(username); + } + } + // Read again: the statements above each cleared what the session had + // loaded, the profile that was described among it. + Profile profile = profiles.findById(username); + if (profile != null) { + profiles.deleteById(username); + } + // The address is retired with the account, in the same transaction: + // nobody can register it and inherit the invoices kept under it. + retire(username); + // Last: the tokens in hand stop working when the sign-in is gone. + users.deleteUser(username); + grants.endAll(username); + return was; + } + + /// Retires an e-mail for good, as only a hash of it. + /// + /// A delivery outlives the account that took it, under the e-mail that account + /// signed in with, and so does a token until it expires. Were the address + /// free to register again, whoever took it would be handed the deliveries, the + /// receipts and the ratings of the person who left, and that person's old + /// token would sign in to the new account. Only a hash is kept: enough to + /// refuse it, and not a list of who has left. + private void retire(String username) throws IOException { + String hash = Ids.sha256(username); + if (!closed.existsById(hash)) { + ClosedAccount row = new ClosedAccount(); + row.setUsernameHash(hash); + row.setClosedAt(Instant.now()); + closed.insert(row); + } + } + + /// Makes the rest of the caller's transaction take its turn with any other + /// that holds the same account. See [ProfileRepository#hold]. Answers 404 + /// when the account is gone: it was closed while the request was under + /// way, its clean-up has run, and a row the caller went on to insert would + /// never be deleted. + public void hold(String username) { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + @Transactional(rollbackFor = IOException.class) + public void markPhoneVerified(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setPhoneVerified(true); + } + } + + /// Makes a change one owner is making to an account take its turn with any + /// other touching either of the two, and then checks `by` still is an + /// owner. Call it before anything is read. + /// + /// Without it two owners could each take the other's role, or block each + /// other, at the same moment. Each request was authorized before the other + /// committed and neither touches its own account, so both would go through + /// and the service would be left with nobody to run it. Both + /// profiles are held, in the order of their names so that the two requests + /// queue and cannot deadlock; whichever comes second then reads what the + /// first left, finds it is no longer an owner, and is refused. + private void actingOwner(String by, String username) { + if (by == null || username == null) { + return; + } + String first = by.compareTo(username) <= 0 ? by : username; + String second = first.equals(by) ? username : by; + profiles.hold(first); + if (!second.equals(first)) { + profiles.hold(second); + } + if (!users.userExists(by)) { + throw new ResponseStatusException(403, "You are no longer an owner"); + } + UserDetails acting = users.loadUserByUsername(by); + if (!acting.isEnabled() || !authorities(acting).contains("ROLE_" + Roles.OWNER)) { + throw new ResponseStatusException(403, "You are no longer an owner"); + } + } + + private UserDetails load(String username) { + if (username == null || !users.userExists(username)) { + // A member with a profile and no sign-in can be looked at, and + // has nothing to give roles to or block until they sign in. + if (username != null && profiles.findById(username) != null) { + throw new ResponseStatusException(409, + "This member has not signed in to the app yet"); + } + throw new ResponseStatusException(404, "No such account"); + } + return users.loadUserByUsername(username); + } + + private static String[] roles(boolean trainer, boolean desk, boolean owner) { + List roles = new ArrayList(); + roles.add(Roles.MEMBER); + if (trainer) { + roles.add(Roles.TRAINER); + } + if (desk) { + roles.add(Roles.DESK); + } + if (owner) { + roles.add(Roles.OWNER); + } + return roles.toArray(new String[roles.size()]); + } + + private static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + private static List authorities(UserDetails signIn) { + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + held.add(it.next().getAuthority()); + } + return held; + } + + private static UserDto toDto(Profile profile, List authorities, boolean enabled) { + return new UserDto(profile.getUsername(), profile.getDisplayName(), profile.getPhone(), + profile.isPhoneVerified(), authorities.contains("ROLE_" + Roles.MEMBER), + authorities.contains("ROLE_" + Roles.TRAINER), + authorities.contains("ROLE_" + Roles.DESK), + authorities.contains("ROLE_" + Roles.OWNER), !enabled, profile.isFlagged(), + profile.getFlagReason(), profile.getLanguage()); + } + + /// A birth date as the app sends it, `yyyy-MM-dd`. Answers 400 for one + /// that is not a date, or is in the future. + public static LocalDate birthDate(String value) { + LocalDate date; + try { + date = LocalDate.parse(value == null ? "" : value.trim()); + } catch (DateTimeParseException bad) { + throw new ResponseStatusException(400, "That is not a date of birth"); + } + if (date.isAfter(LocalDate.now()) || date.getYear() < 1900) { + throw new ResponseStatusException(400, "That is not a date of birth"); + } + return date; + } + + /// Whole years between a birth date and a day. + public static int age(LocalDate born, LocalDate on) { + int years = on.getYear() - born.getYear(); + if (on.getMonthValue() < born.getMonthValue() || on.getMonthValue() == born.getMonthValue() + && on.getDayOfMonth() < born.getDayOfMonth()) { + years--; + } + return years; + } + + /// An e-mail address as an account name: trimmed, and its ASCII letters in + /// lower case. Folded by hand, because `toLowerCase()` follows the server's + /// locale and a key must not: under a Turkish locale the `I` of `INFO@` would + /// fold to a dotless i and name a different account. + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + /// A phone number in the form it is sent to and stored in: a `+` and its + /// digits, with the spaces, dashes and brackets people type taken out. + static String phone(String value) { + String text = value == null ? "" : value.trim(); + StringBuilder digits = new StringBuilder(); + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c >= '0' && c <= '9') { + digits.append(c); + } else if (c != ' ' && c != '-' && c != '(' && c != ')' && c != '.' + && !(c == '+' && iter == 0)) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + } + if (digits.length() < 7 || digits.length() > 15) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + return "+" + digits; + } + + private static String required(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/DemoAccounts.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/DemoAccounts.java new file mode 100644 index 00000000000..19ac59292fa --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/DemoAccounts.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.api.CardDto; +import com.codenameone.examples.pulse.api.PaymentSetupDto; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.pay.Payments; +import com.codenameone.examples.pulse.repository.ProfileRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; + +/// The accounts a server starts with. +/// +/// On a development profile, one of each kind, so that every screen of the +/// app has someone to show it: members (one with children, one the owners +/// flagged), two trainers, the front desk and the owner. The studio itself -- +/// locations, classes, plans and a few weeks of history -- is +/// `studio/DemoStudio`'s, which runs after this. +/// +/// Anywhere else, at most one: the owner named by `pulse.admin.email` and +/// `pulse.admin.password` (or the same two names in the environment, in upper +/// case with `_` for each `.`), because a new deployment needs somebody able +/// to give the first roles. It is created once; changing the setting later +/// does not change the password of an account that exists. +@Component +public class DemoAccounts { + /// A member with two children in their household and a card on file. + public static final String MEMBER = "member@pulse.example"; + /// A member with no household and no card, as a new sign-up is. + public static final String SECOND_MEMBER = "member2@pulse.example"; + /// A member the owners have flagged. + public static final String FLAGGED_MEMBER = "member3@pulse.example"; + /// A member who has not been in for weeks, for the we-miss-you nudges. + public static final String LAPSED_MEMBER = "member4@pulse.example"; + public static final String TRAINER = "trainer@pulse.example"; + public static final String SECOND_TRAINER = "trainer2@pulse.example"; + /// Pilates and barre at Downtown. + public static final String PILATES_TRAINER = "trainer3@pulse.example"; + /// The ride and HIIT coach at Downtown. + public static final String RIDE_TRAINER = "trainer4@pulse.example"; + /// Yoga and climbing at Riverside. + public static final String RIVERSIDE_TRAINER = "trainer5@pulse.example"; + public static final String DESK = "desk@pulse.example"; + public static final String OWNER = "owner@pulse.example"; + /// The password of every demo account. Not a secret: these accounts exist + /// only where the profile says the server is not a real one. + public static final String PASSWORD = "pulse-demo"; + + private final Accounts accounts; + private final Moderation moderation; + private final Payments payments; + private final ProfileRepository profiles; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Moderation moderation, Payments payments, + ProfileRepository profiles, Config config, + @Value("${pulse.admin.email:}") String adminEmail, + @Value("${pulse.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.moderation = moderation; + this.payments = payments; + this.profiles = profiles; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + /// Runs when the server starts, called by `studio/DemoStudio` before it + /// seeds the studio. Not a transaction itself: each + /// account is made by a transactional method, so a server stopped half + /// way through start-up is left with whole accounts, and the next start + /// adds the ones that are missing. + public void seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8) { + add(Accounts.email(adminEmail), adminPassword, "Owner", "+10000000", + LocalDate.of(1980, 1, 1), false, false, true, 0); + } + return; + } + // The tests sign in as the first member and expect to find them as a + // new account is: no card saved. Under the `test` profile only the + // others get one. + boolean card = !"test".equals(config.getProfile()); + if (add(MEMBER, PASSWORD, "Riley Park", "+15550100", LocalDate.of(1987, 5, 14), + false, false, false, 120) && card) { + card(MEMBER, "Riley Park"); + } + add(SECOND_MEMBER, PASSWORD, "Robin Chen", "+15550101", LocalDate.of(1995, 9, 2), + false, false, false, 3); + if (add(FLAGGED_MEMBER, PASSWORD, "Rae Okafor", "+15550102", LocalDate.of(1990, 2, 20), + false, false, false, 60)) { + flag(FLAGGED_MEMBER, "Three no-shows to full classes in one week", 4); + } + if (add(LAPSED_MEMBER, PASSWORD, "Lee Moreno", "+15550103", LocalDate.of(1979, 11, 8), + false, false, false, 200)) { + card(LAPSED_MEMBER, "Lee Moreno"); + } + add(TRAINER, PASSWORD, "Tess Alvarez", "+15550110", LocalDate.of(1991, 3, 30), true, + false, false, 400); + add(SECOND_TRAINER, PASSWORD, "Theo Brooks", "+15550111", LocalDate.of(1985, 7, 19), + true, false, false, 300); + add(PILATES_TRAINER, PASSWORD, "Maya Lindqvist", "+15550114", LocalDate.of(1989, 6, 11), + true, false, false, 380); + add(RIDE_TRAINER, PASSWORD, "Andre Okoye", "+15550115", LocalDate.of(1993, 1, 25), true, + false, false, 340); + add(RIVERSIDE_TRAINER, PASSWORD, "Sofia Marin", "+15550116", LocalDate.of(1988, 10, 3), + true, false, false, 270); + add(DESK, PASSWORD, "Dee Patel", "+15550112", LocalDate.of(1998, 12, 1), false, true, + false, 250); + add(OWNER, PASSWORD, "Olive Hart", "+15550113", LocalDate.of(1976, 4, 4), false, + false, true, 500); + } + + /// Marks an account as one the owners flagged some days ago, with the + /// event that says who did and why. + @Transactional(rollbackFor = IOException.class) + void flag(String username, String why, int daysAgo) throws IOException { + Profile profile = profiles.findById(username); + profile.setFlagged(true); + profile.setFlagReason(why); + moderation.record(username, OWNER, Moderation.FLAG, why, + Instant.now().minusMillis(daysAgo * Days.MILLIS)); + } + + /// Makes an account unless it exists. True when this call made it. + private boolean add(String username, String password, String name, String phone, + LocalDate born, boolean trainer, boolean desk, boolean owner, int daysAgo) + throws IOException { + if (accounts.exists(username)) { + return false; + } + try { + accounts.create(username, password, name, phone, true, born, trainer, desk, owner, + System.currentTimeMillis() - daysAgo * Days.MILLIS); + return true; + } catch (IOException | RuntimeException failed) { + // Two servers started together against a new database both find + // no owner and both make one; the second insert fails on the name. + // create() is one transaction, so the loser left nothing behind, + // and the account the other server made is the one wanted. Any + // other failure leaves no account, and is the start-up's to report. + if (!accounts.exists(username)) { + throw failed; + } + return false; + } + } + + /// Saves a test card. Only a simulated card can be made up: with a real + /// processor configured the demo members pay at the desk until someone + /// adds a card. + private void card(String username, String name) throws IOException { + PaymentSetupDto setup = payments.startSetup(username, name); + if (!setup.hosted()) { + int year = Integer.parseInt(Days.iso(System.currentTimeMillis()).substring(0, 4)); + payments.completeSetup(username, setup.id(), + new CardDto("4242424242424242", 12, year + 3, "123", name)); + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Grants.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Grants.java new file mode 100644 index 00000000000..cb97b5d7a71 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Grants.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + private static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Moderation.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Moderation.java new file mode 100644 index 00000000000..80e403c10db --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Moderation.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.ModerationEventDto; +import com.codenameone.examples.pulse.domain.ModerationEvent; +import com.codenameone.examples.pulse.repository.ModerationEventRepository; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// The record of what the admins did to an account, and why. +/// +/// Flagging, blocking, approving and rejecting all change an account's state, +/// and the state alone does not say who decided it or on what grounds. This +/// does. A row is written with the change and never altered afterwards. +@Component +public class Moderation { + public static final String FLAG = "flag"; + public static final String UNFLAG = "unflag"; + public static final String BLOCK = "block"; + public static final String UNBLOCK = "unblock"; + public static final String APPROVE = "approve"; + public static final String REJECT = "reject"; + public static final String ROLES = "roles"; + /// A restaurant application approved; the column holds 16 characters. + public static final String LISTED = "listed"; + public static final String CANCEL_ORDER = "cancel_order"; + + private final ModerationEventRepository events; + + public Moderation(ModerationEventRepository events) { + this.events = events; + } + + @Transactional + public void record(String username, String by, String action, String reason) + throws IOException { + // Two changes made in the same millisecond -- a block and the flag that + // came with it -- would otherwise be listed in either order. The later + // one is stamped after the one before it, so the list is the order in + // which things were done. + Instant newest = events.newest(username); + Instant now = Instant.now(); + Instant after = newest == null ? Instant.EPOCH : newest.plusMillis(1L); + record(username, by, action, reason, after.isAfter(now) ? after : now); + } + + /// As [#record(String, String, String, String)], at a time of the caller's + /// choosing; the demo data is the one caller that has a past to write. + @Transactional + public void record(String username, String by, String action, String reason, Instant at) + throws IOException { + String why = reason == null ? "" : reason.trim(); + ModerationEvent event = new ModerationEvent(); + event.setId(Ids.next()); + event.setUsername(username); + event.setCreatedAt(at); + event.setActor(by); + event.setAction(action); + event.setReason(why.length() > 255 ? why.substring(0, 255) : why); + events.insert(event); + } + + /// An account's events, newest first. + @Transactional(readOnly = true) + public List events(String username) throws IOException { + List rows = events.findTop100ByUsernameOrderByCreatedAtDescIdAsc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ModerationEvent row = rows.get(iter); + out.add(new ModerationEventDto(row.getCreatedAt().toEpochMilli(), row.getActor(), row.getAction(), + row.getReason())); + } + return out; + } + + @Transactional + public void forget(String username) throws IOException { + events.deleteByUsername(username); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/PhoneVerification.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/PhoneVerification.java new file mode 100644 index 00000000000..4ac403cc9b0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/PhoneVerification.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.Config; +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.PhoneChallengeDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.domain.PhoneCode; +import com.codenameone.examples.pulse.repository.PhoneCodeRepository; +import com.codenameone.examples.pulse.sms.SmsSender; + +import java.io.IOException; +import java.time.Instant; + +/// Proves an account's phone number by texting it a code. +/// +/// The server makes the code, keeps only its hash, and is the one that decides +/// whether what the user typed matches. The app never sees the code it is asking +/// the user for, so it cannot be talked into accepting one. +@Component +public class PhoneVerification { + /// How long a code stays good. + static final int CODE_SECONDS = 300; + /// Wrong guesses allowed before the code is void. Six digits and five + /// guesses, each against at most two live codes (see [#store]), is one + /// chance in 100,000 per code sent. + static final int MAX_ATTEMPTS = 5; + /// The least time between two codes for one account. Each one is a text + /// message somebody pays for and somebody receives. + static final int RESEND_SECONDS = 30; + + private final PhoneCodeRepository codes; + private final Accounts accounts; + private final SmsSender sms; + private final Config config; + + public PhoneVerification(PhoneCodeRepository codes, Accounts accounts, SmsSender sms, + Config config) { + this.codes = codes; + this.accounts = accounts; + this.sms = sms; + this.config = config; + } + + /// Sends a new code, replacing the one before it. + /// + /// Not one transaction, because a text message is sent in the middle and a + /// transaction must not be held open across a call that leaves the server. + /// The code is stored and committed, then sent, and taken back in a second + /// transaction when it could not be. + public PhoneChallengeDto start(String username) throws IOException { + UserDto user = accounts.describe(username); + String code = newCode(); + String codeHash = hash(username, code); + store(username, codeHash, System.currentTimeMillis()); + try { + sms.send(user.phone(), "Your Pulse code is " + code); + } catch (IOException failed) { + System.err.println("could not send a verification code: " + failed.getMessage()); + discard(username, codeHash); + throw new ResponseStatusException(503, "We could not send the code. Try again shortly"); + } + // Only a server that cannot send a text message, and only on a + // development profile, tells the app what the code was. Both halves + // matter: a production server whose SMS settings were forgotten must fail + // to verify anyone, not verify everyone. + String demoCode = !sms.delivers() && config.isDevelopmentProfile() ? code : null; + return new PhoneChallengeDto(CODE_SECONDS, demoCode); + } + + /// Keeps the hash of a new code in place of the one before it. Answers 429 + /// when the one before it was sent a moment ago. + /// + /// The code before it stays good until it would have expired anyway. A + /// second code is asked for when the first is slow to arrive, and text + /// messages are not delivered in order: the first may arrive last, and the + /// newest message on the phone must not be a code the server refuses. Only + /// one is kept, so at most two codes are live, and only one whose guesses + /// were not used up. + @Transactional + void store(String username, String codeHash, long now) throws IOException { + // Before the code is read: two requests for one account take turns, + // so the second finds the code the first just stored and is told to + // wait. Otherwise both pass the check below and both send a text. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending != null && now - pending.getSentAt().toEpochMilli() < RESEND_SECONDS * 1000L) { + throw new ResponseStatusException(429, "Wait a moment before asking for another code"); + } + boolean first = pending == null; + if (first) { + pending = new PhoneCode(); + pending.setUsername(username); + } else if (pending.getExpiresAt().toEpochMilli() >= now && pending.getAttempts() < MAX_ATTEMPTS) { + // With the guesses it has used: asking for another code must not + // buy the old one a fresh set. + pending.setPreviousHash(pending.getCodeHash()); + pending.setPreviousExpiresAt(pending.getExpiresAt()); + pending.setPreviousAttempts(pending.getAttempts()); + } else { + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(Instant.EPOCH); + pending.setPreviousAttempts(0); + } + pending.setCodeHash(codeHash); + pending.setExpiresAt(Instant.ofEpochMilli(now + CODE_SECONDS * 1000L)); + pending.setAttempts(0); + pending.setSentAt(Instant.ofEpochMilli(now)); + if (first) { + codes.insert(pending); + } + } + + /// Takes back a code that could not be sent, if it is still the one the + /// account is waiting on. A send can take longer to fail than the wait + /// between two codes, and by then a later request may have stored a code + /// of its own and had it delivered: that one is the user's, and stays. + /// The code before it, when it is still good, is the one waited on again, + /// and another may be asked for at once. + @Transactional + void discard(String username, String codeHash) throws IOException { + // The hold store() takes, before the row is read: a resend storing its + // code meanwhile commits first, and this then finds that code and + // leaves it, where read before the wait it would overwrite or delete it. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || !codeHash.equals(pending.getCodeHash())) { + return; + } + if (pending.getPreviousHash().length() == 0 + || pending.getPreviousExpiresAt().isBefore(Instant.now())) { + codes.deleteById(username); + return; + } + pending.setCodeHash(pending.getPreviousHash()); + pending.setExpiresAt(pending.getPreviousExpiresAt()); + pending.setAttempts(pending.getPreviousAttempts()); + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(Instant.EPOCH); + pending.setPreviousAttempts(0); + pending.setSentAt(Instant.EPOCH); + } + + /// Checks the code the user typed. Answers 400 for a wrong or expired one + /// and 429 once the guesses are used up. + /// + /// The refusal of a wrong code is thrown here, outside the transaction that + /// counted the guess. Thrown inside it, the error would roll the count back + /// and every code could be guessed at for ever. + public UserDto verify(String username, String code) throws IOException { + if (!guess(username, code)) { + throw new ResponseStatusException(400, "That is not the code we sent"); + } + return accounts.describe(username); + } + + /// Takes one guess at an account's code and answers whether it was right. + /// A right one uses the code up and marks the phone verified; a wrong one + /// leaves the guess counted, which is why this returns false where it + /// could have thrown. + @Transactional + boolean guess(String username, String code) throws IOException { + // The hold store() and discard() take, and in the same order as a + // closure, which holds the profile and then deletes the code: taken + // the other way round, a guess and a closure could each wait on the + // row the other has. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || pending.getExpiresAt().isBefore(Instant.now())) { + throw new ResponseStatusException(400, "That code has expired. Ask for a new one"); + } + if (pending.getAttempts() >= MAX_ATTEMPTS) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + String expected = pending.getCodeHash(); + // Taken before the comparison, and by the database, which also holds + // the limit: the count read above is what it was when this request + // arrived, and any number of requests arriving together all read the + // same one. Only a request that is given a guess has its code looked at. + // And only of the code read above: one sent meanwhile is not this one. + if (codes.takeGuess(username, expected, MAX_ATTEMPTS) != 1) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + // Read again after the guess was taken, which the update did under + // the row's lock: the count of the code before is then this guess's + // own, and within its limit only when this guess was one it had left. + PhoneCode counted = codes.findById(username); + String typed = code == null ? "" : code.trim(); + byte[] given = hash(username, typed).getBytes("UTF-8"); + // Both compared every time, so the answer takes as long either way. The + // code before is good only until it would have expired; it was sent + // first, so it never outlives the current one checked above. + // + // `counted.getPreviousAttempts()` already includes every guess made + // since the resend: takeGuess adds one to `previousAttempts` in the + // same statement that adds one to `attempts`, so it is the old code's + // whole count, not the snapshot store() copied. A code that used four + // guesses before the resend reads 5 on its first guess after it (still + // allowed: its fifth) and 6 on the next (refused), so its budget stays + // five. AccountTest.theCodeBeforeKeepsTheGuessesItHadLeft holds that. + boolean current = Crypto.equalsConstantTime(given, expected.getBytes("UTF-8")); + boolean previous = Crypto.equalsConstantTime(given, + pending.getPreviousHash().getBytes("UTF-8")) + && pending.getPreviousHash().length() > 0 + && !pending.getPreviousExpiresAt().isBefore(Instant.now()) + && counted != null && counted.getPreviousAttempts() <= MAX_ATTEMPTS; + if (!current && !previous) { + return false; + } + pending = codes.findById(username); + if (pending != null) { + codes.deleteById(username); + } + accounts.markPhoneVerified(username); + return true; + } + + /// Six digits, each value as likely as the next: a draw that would favour + /// the low numbers is thrown away and drawn again. + private static String newCode() throws IOException { + while (true) { + byte[] random = Crypto.randomBytes(4); + int value = ((random[0] & 0x7f) << 24) | ((random[1] & 0xff) << 16) + | ((random[2] & 0xff) << 8) | (random[3] & 0xff); + if (value < 2147000000) { + String digits = String.valueOf(1000000 + value % 1000000); + return digits.substring(1); + } + } + } + + /// An unkeyed digest, kept so on purpose. Somebody who can read the + /// `phone_code` table could try every code within the five minutes one + /// lasts, but somebody who can read the database already owns what a + /// phone check guards: it proves a number, and grants nothing on its own. + /// A keyed digest under a server secret is a hardening step for a + /// production deployment, listed in the implementor's guide among what the + /// sample leaves out. + private static String hash(String username, String code) throws IOException { + return Ids.sha256(username + ":" + code); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Preferences.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Preferences.java new file mode 100644 index 00000000000..5c1f701ad18 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/account/Preferences.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Text; +import com.codenameone.examples.pulse.api.PreferencesDto; +import com.codenameone.examples.pulse.domain.Preference; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.pay.Payments; +import com.codenameone.examples.pulse.repository.PreferenceRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; + +import java.io.IOException; + +/// How one person wants the app: language, theme, units, what they are told +/// about and when they are not to be disturbed, and the card their +/// membership is charged to. +/// +/// Kept on the server so that they follow the account from one phone to the +/// next. Two of them live elsewhere, because something else reads them: the +/// language is in the profile, where the owner sees it, and the default way to +/// pay is the mark on the saved card itself, so that removing a card cannot +/// leave a preference pointing at it. +@Component +public class Preferences { + private static final String[] LANGUAGES = {"en", "es", "fr", "de", "he"}; + private static final String[] THEMES = {"system", "light", "dark"}; + private static final String[] UNITS = {"km", "mi"}; + /// The quiet hours someone who never opened the settings gets: from ten + /// at night to seven in the morning, the server's time. + public static final int QUIET_FROM = 22; + public static final int QUIET_TO = 7; + + private final ProfileRepository profiles; + private final PreferenceRepository preferences; + private final Payments payments; + + public Preferences(ProfileRepository profiles, PreferenceRepository preferences, + Payments payments) { + this.profiles = profiles; + this.preferences = preferences; + this.payments = payments; + } + + @Transactional(readOnly = true) + public PreferencesDto get(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + Preference row = preferences.findById(username); + String language = profile.getLanguage(); + String method = payments.defaultMethod(username); + if (row == null) { + // What someone who never opened the settings gets. + return new PreferencesDto(language, THEMES[0], UNITS[0], true, true, true, method, + QUIET_FROM, QUIET_TO); + } + return new PreferencesDto(language, row.getTheme(), row.getUnits(), row.isNotifyClasses(), + row.isNotifyReceipts(), row.isNotifyPromotions(), method, row.getQuietFrom(), + row.getQuietTo()); + } + + /// Saves all of them. Each is checked against the values it can take: these + /// are words the server later acts on, not text to show back. One + /// transaction, so a value that is refused leaves none of the others saved. + @Transactional + public PreferencesDto save(String username, PreferencesDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + // First, before anything is read: two saves sent together for an + // account with no row of settings yet would each find none and each + // insert one, and the second insert fails on the key. The profile is + // there from registration, so held, one save waits and finds the + // other's row. + profiles.hold(username); + // Hours of the day, from and to; the same hour twice means no quiet + // hours at all. + if (wanted.quietFrom() < 0 || wanted.quietFrom() > 23 || wanted.quietTo() < 0 + || wanted.quietTo() > 23) { + throw new ResponseStatusException(400, "Quiet hours are hours of the day, 0 to 23"); + } + String theme = Text.oneOf(wanted.theme(), THEMES, THEMES[0], "the theme"); + String units = Text.oneOf(wanted.units(), UNITS, UNITS[0], "the units"); + String language = Text.oneOf(wanted.language(), LANGUAGES, LANGUAGES[0], "the language"); + String method = wanted.defaultPaymentMethodId() == null ? "" + : wanted.defaultPaymentMethodId().trim(); + if (method.length() > 0 && !method.equals(payments.defaultMethod(username))) { + // Answers 404 for a card that is not this user's, before anything + // else is saved. + payments.makeDefault(username, method); + } + // Read after the card was marked, not before: marking one may be a + // statement over every card of the account, and what was read before + // such a statement is no longer the session's to write. + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + profile.setLanguage(language); + Preference row = preferences.findById(username); + boolean unsaved = row == null; + if (unsaved) { + row = new Preference(); + row.setUsername(username); + } + row.setTheme(theme); + row.setUnits(units); + row.setNotifyClasses(wanted.notifyClasses()); + row.setNotifyReceipts(wanted.notifyReceipts()); + row.setNotifyPromotions(wanted.notifyPromotions()); + row.setQuietFrom(wanted.quietFrom()); + row.setQuietTo(wanted.quietTo()); + if (unsaved) { + preferences.insert(row); + } + return get(username); + } + + /// Whether `hour` falls in someone's quiet hours, which may run past + /// midnight. Nudges are held back then; what they asked for -- a waitlist + /// place, a class cancelled -- is not. + public static boolean quiet(int from, int to, int hour) { + if (from == to) { + return false; + } + return from < to ? hour >= from && hour < to : hour >= from || hour < to; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/admin/AdminEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/admin/AdminEndpoint.java new file mode 100644 index 00000000000..00d17e77de4 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/admin/AdminEndpoint.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.account.Moderation; +import com.codenameone.examples.pulse.api.AdminApiServer; +import com.codenameone.examples.pulse.api.FlagDto; +import com.codenameone.examples.pulse.api.RoleChangeDto; +import com.codenameone.examples.pulse.api.SuspendDto; +import com.codenameone.examples.pulse.api.UserDetailDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.studio.Household; + +import java.util.List; + +/// The server's half of `AdminApi`: the owner's view of the people with +/// accounts. Guarded by path in the filter chain and by role here. +/// +/// This is one of the few places a name in the path chooses whose data is +/// read. Everywhere a member is the caller, they are the subject. +@Component +@PreAuthorize("hasRole('OWNER')") +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final Household household; + private final Moderation moderation; + private final CheckInRepository checkIns; + private final BookingRepository bookings; + private final InvoiceRepository invoices; + private final LiveHub live; + + public AdminEndpoint(Accounts accounts, Household household, Moderation moderation, + CheckInRepository checkIns, BookingRepository bookings, InvoiceRepository invoices, + LiveHub live) { + this.accounts = accounts; + this.household = household; + this.moderation = moderation; + this.checkIns = checkIns; + this.bookings = bookings; + this.invoices = invoices; + this.live = live; + } + + @Override + public List users() throws Exception { + return accounts.all(); + } + + @Override + @Transactional(readOnly = true) + public UserDetailDto user(String username) throws Exception { + UserDto user = accounts.describe(username); + Long spent = invoices.spent(username); + return new UserDetailDto(user, household.list(username), + (int) checkIns.countByAccount(username), + (int) bookings.countByAccountAndState(username, "NO_SHOW"), + spent == null ? 0L : spent.longValue(), moderation.events(username)); + } + + @Override + public UserDto setRoles(String username, RoleChangeDto roles) throws Exception { + if (roles == null) { + throw new ResponseStatusException(400, "Say which roles"); + } + return accounts.setRoles(Caller.name(), username, roles.trainer(), roles.desk(), + roles.owner()); + } + + @Override + public UserDto suspend(String username, SuspendDto suspend) throws Exception { + UserDto user = accounts.suspend(Caller.name(), username, + suspend != null && suspend.suspended(), suspend == null ? "" : suspend.reason()); + if (user.suspended()) { + // The API turns a suspended account away on its next request. A + // live channel already open makes no further request, so it is + // closed here. + live.drop(username); + } + return user; + } + + @Override + public UserDto flag(String username, FlagDto flag) throws Exception { + return accounts.flag(Caller.name(), username, flag != null && flag.flagged(), + flag == null ? "" : flag.reason()); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/Billing.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/Billing.java new file mode 100644 index 00000000000..461732928b8 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/Billing.java @@ -0,0 +1,799 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.billing; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.FreezeDto; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.JoinDto; +import com.codenameone.examples.pulse.api.MembershipDto; +import com.codenameone.examples.pulse.api.PlanDto; +import com.codenameone.examples.pulse.domain.Credit; +import com.codenameone.examples.pulse.domain.Invoice; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.pay.PaymentDeclinedException; +import com.codenameone.examples.pulse.pay.Payments; +import com.codenameone.examples.pulse.repository.CreditRepository; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.studio.Household; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// Memberships and what they cost: joining, renewing, freezing, cancelling, +/// and getting paid when a card says no. +/// +/// A charge is three steps, and only the middle one talks to the processor: +/// a transaction writes the invoice and claims it ([InvoiceRepository#claim]), +/// the card is charged with no transaction open, and a second transaction +/// records what happened. The invoice id is the processor's idempotency key, +/// so a server that dies between the second and third step charges the same +/// invoice again on the next run and is answered the first charge, not a +/// second one. +/// +/// The dunning ladder: a failed renewal is retried after [#RETRY_DAYS] days +/// each, and the membership stays usable through the first retries -- most +/// failures are a card that expired, mended within a day. From the +/// [#PAST_DUE_AFTER]th failure the membership is past due and the door says +/// so; after the last retry the invoice waits for the member to pay it by hand. +@Component +public class Billing { + /// Membership states, beside [Household#CURRENT]'s. + public static final String ACTIVE = "ACTIVE"; + public static final String FROZEN = "FROZEN"; + public static final String PAST_DUE = "PAST_DUE"; + public static final String PENDING = "PENDING"; + public static final String CANCELLED = "CANCELLED"; + public static final String EXPIRED = "EXPIRED"; + /// Invoice states. `CHARGING` is held only while one attempt is under way. + public static final String OPEN = "OPEN"; + public static final String CHARGING = "CHARGING"; + public static final String PAID = "PAID"; + public static final String FAILED = "FAILED"; + public static final String VOID = "VOID"; + /// Days from each failed attempt to the next. + static final int[] RETRY_DAYS = {1, 3, 7}; + /// The failure from which a membership is past due. + static final int PAST_DUE_AFTER = 2; + /// What a referrer is given when the member they brought pays for the + /// first time, in minor units. + static final long REFERRAL_CENTS = 2000L; + /// A freeze is a week at least and three months at most. + static final int FREEZE_MIN_DAYS = 7; + static final int FREEZE_MAX_DAYS = 90; + /// How long a class pack or a drop-in can be used. + static final int PACK_MONTHS = 12; + /// The `nextAttemptAt` of an invoice no job will try again. + static final Instant NEVER = Instant.ofEpochMilli(4102444800000L); + private static final long DAY = 86400000L; + private static final List LIVE = Arrays.asList(ACTIVE, FROZEN, PAST_DUE, PENDING); + + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final InvoiceRepository invoices; + private final CreditRepository credits; + private final MemberRepository members; + private final ProfileRepository profiles; + private final Payments payments; + private final Household household; + + public Billing(MembershipRepository memberships, MembershipPlanRepository plans, + InvoiceRepository invoices, CreditRepository credits, MemberRepository members, + ProfileRepository profiles, Payments payments, Household household) { + this.memberships = memberships; + this.plans = plans; + this.invoices = invoices; + this.credits = credits; + this.members = members; + this.profiles = profiles; + this.payments = payments; + this.household = household; + } + + // ------------------------------------------------------------- plans + + @Transactional(readOnly = true) + public List plans(boolean all) { + List rows = all ? plans.findAllByOrderByPriceCentsAsc() + : plans.findByActiveTrueOrderByPriceCentsAsc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(plan(rows.get(iter))); + } + return out; + } + + public static PlanDto plan(MembershipPlan row) { + return new PlanDto(row.getId(), row.getName(), row.getDescription(), row.getKind(), + row.getPriceCents(), row.getCurrency(), row.getPeriodMonths(), + row.getClassCredits(), row.getMinAge(), row.getMaxAge(), row.isOffPeakOnly(), + row.isActive()); + } + + // ------------------------------------------------------------- memberships + + @Transactional(readOnly = true) + public List memberships(String account) { + List rows = memberships.findByAccountOrderByStartedAtDesc(account); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Puts a member of the caller's household on a plan and takes the first + /// payment. A member already on a plan moves to the new one, and what is + /// left of the old period comes off the first invoice ([#prorate]). + /// + /// Paid in cash, the membership is pending until the desk takes the money. + /// Declined, nothing is kept but the declined invoice: a member never + /// holds a membership they did not pay for. + public MembershipDto join(String account, JoinDto request) throws IOException { + String[] method = payments.choose(account, request == null ? null : request.methodId()); + String[] made = open(account, request, method[0]); + if (!Payments.CASH.equals(method[0])) { + try { + collect(made[1], true); + } catch (ResponseStatusException declined) { + abandon(made[0], made[1]); + throw declined; + } + } + return membership(made[0]); + } + + @Transactional(readOnly = true) + MembershipDto membership(String id) { + return toDto(memberships.findById(id)); + } + + @Transactional(readOnly = true) + InvoiceDto invoice(String id) { + return toDto(invoices.findById(id)); + } + + @Transactional(readOnly = true) + Invoice owedBy(String account, String id) { + return id == null || id.length() > 32 ? null : invoices.findFirstByIdAndAccount(id, account); + } + + /// Undoes a join whose first payment was refused: the membership never + /// starts, and its invoice is kept, void, as the record of the attempt. + /// A previous plan the join had ended stays ended -- the member chose to + /// leave it -- but its unused time stays on the account as credit. + @Transactional(rollbackFor = IOException.class) + void abandon(String membershipId, String invoiceId) { + Membership row = memberships.findById(membershipId); + row.setState(CANCELLED); + row.setCurrentPeriodEnd(row.getStartedAt()); + row.setUpdatedAt(Instant.now()); + Invoice bill = invoices.findById(invoiceId); + if (!PAID.equals(bill.getStatus())) { + bill.setStatus(VOID); + bill.setNextAttemptAt(NEVER); + release(bill); + } + } + + /// Writes the membership and its first invoice. + /// + /// @return the membership's id and the invoice's + @Transactional(rollbackFor = IOException.class) + String[] open(String account, JoinDto request, String methodId) throws IOException { + if (request == null || request.planId() == null) { + throw new ResponseStatusException(400, "Choose a plan"); + } + // No member named is the account holder joining for themselves. + Member member = request.memberId() == null + ? members.findFirstByAccountAndRelation(account, Accounts.SELF) + : household.mine(account, request.memberId()); + if (member == null) { + throw new ResponseStatusException(404, "No such member"); + } + MembershipPlan plan = plans.findById(request.planId()); + if (plan == null || !plan.isActive()) { + throw new ResponseStatusException(404, "No such plan"); + } + int age = Accounts.age(member.getBirthDate(), LocalDate.now(ZoneOffset.UTC)); + if (plan.getMinAge() > 0 && age < plan.getMinAge() + || plan.getMaxAge() > 0 && age > plan.getMaxAge()) { + throw new ResponseStatusException(409, member.getName() + " is not of an age for " + + plan.getName()); + } + if (!Household.signed(member)) { + throw new ResponseStatusException(409, "Sign the waiver for " + member.getName() + + " first"); + } + Instant now = Instant.now(); + long credit = 0; + Membership old = household.current(member.getId()); + if (old != null) { + if (PAST_DUE.equals(old.getState()) || PENDING.equals(old.getState())) { + throw new ResponseStatusException(409, "Settle what is owed on " + + member.getName() + "'s membership first"); + } + if (old.getPlanId().equals(plan.getId())) { + throw new ResponseStatusException(409, member.getName() + " is on that plan"); + } + MembershipPlan was = plans.findById(old.getPlanId()); + credit = was == null || Household.counted(was) ? 0 + : prorate(was.getPriceCents(), old.getCurrentPeriodEnd(), was.getPeriodMonths(), + now); + old.setState(CANCELLED); + old.setCurrentPeriodEnd(now); + old.setUpdatedAt(now); + } + Membership row = new Membership(); + row.setId(Ids.next()); + row.setMemberId(member.getId()); + row.setAccount(account); + row.setPlanId(plan.getId()); + row.setState(Payments.CASH.equals(methodId) ? PENDING : ACTIVE); + row.setStartedAt(now); + row.setCurrentPeriodEnd(plusMonths(now, Household.counted(plan) ? PACK_MONTHS + : plan.getPeriodMonths())); + row.setCreditsLeft(plan.getClassCredits()); + row.setUpdatedAt(now); + memberships.insert(row); + if (credit > 0) { + give(account, credit, "Unused time on a previous plan", now); + } + Invoice bill = invoice(row, plan, member, now, row.getCurrentPeriodEnd(), methodId); + return new String[] {row.getId(), bill.getId()}; + } + + /// The share of a period's price that has not been used yet, by the + /// millisecond, rounded down to the cent: a member never gets back more + /// than they paid. + public static long prorate(long priceCents, Instant periodEnd, int periodMonths, + Instant now) { + Instant start = plusMonths(periodEnd, -periodMonths); + long whole = periodEnd.toEpochMilli() - start.toEpochMilli(); + long left = periodEnd.toEpochMilli() - now.toEpochMilli(); + if (whole <= 0 || left <= 0) { + return 0; + } + if (left >= whole) { + return priceCents; + } + // Multiplying first loses nothing for any real price: a period is + // under 2^35 ms, a price under 2^27 cents. + return priceCents * left / whole; + } + + /// Freezes a membership between two dates: no billing and no classes + /// meanwhile, and its period is moved on by the frozen days when it + /// ends. Starts at once when `from` is today. + @Transactional(rollbackFor = IOException.class) + public MembershipDto freeze(String account, String id, FreezeDto request) { + Membership row = owned(account, id); + if (request == null) { + throw new ResponseStatusException(400, "Choose the dates"); + } + LocalDate from = date(request.from()); + LocalDate until = date(request.until()); + freeze(row, from, until, LocalDate.now(ZoneOffset.UTC)); + return toDto(row); + } + + /// The freeze itself, also used by an away period that freezes billing. + public void freeze(Membership row, LocalDate from, LocalDate until, LocalDate today) { + MembershipPlan plan = plans.findById(row.getPlanId()); + if (plan != null && Household.counted(plan)) { + throw new ResponseStatusException(409, "A class pack cannot be frozen"); + } + if (!ACTIVE.equals(row.getState()) && !FROZEN.equals(row.getState())) { + throw new ResponseStatusException(409, "Only an active membership can be frozen"); + } + long days = until.toEpochDay() - from.toEpochDay(); + if (from.isBefore(today) || days < FREEZE_MIN_DAYS || days > FREEZE_MAX_DAYS) { + throw new ResponseStatusException(400, "A freeze starts today or later and lasts " + + FREEZE_MIN_DAYS + " to " + FREEZE_MAX_DAYS + " days"); + } + row.setFrozenFrom(start(from)); + row.setFrozenUntil(start(until)); + if (!from.isAfter(today)) { + row.setState(FROZEN); + } + row.setUpdatedAt(Instant.now()); + } + + /// Ends a freeze now, or withdraws one that has not started. The period + /// moves on by the days it was frozen. + @Transactional(rollbackFor = IOException.class) + public MembershipDto unfreeze(String account, String id) { + Membership row = owned(account, id); + if (row.getFrozenUntil().equals(Instant.EPOCH)) { + throw new ResponseStatusException(409, "That membership is not frozen"); + } + thaw(row, Instant.now()); + return toDto(row); + } + + private static void thaw(Membership row, Instant now) { + if (FROZEN.equals(row.getState())) { + long frozen = Math.max(0, Math.min(now.toEpochMilli(), row.getFrozenUntil().toEpochMilli()) + - row.getFrozenFrom().toEpochMilli()); + row.setCurrentPeriodEnd(row.getCurrentPeriodEnd().plusMillis(frozen)); + row.setState(ACTIVE); + } + row.setFrozenFrom(Instant.EPOCH); + row.setFrozenUntil(Instant.EPOCH); + row.setUpdatedAt(now); + } + + /// Cancels at the end of the period paid for, or takes that back when + /// asked again. A pending membership, never paid, ends at once. + @Transactional(rollbackFor = IOException.class) + public MembershipDto cancel(String account, String id) throws IOException { + Membership row = owned(account, id); + if (PENDING.equals(row.getState())) { + row.setState(CANCELLED); + List open = invoices.findByAccountOrderByCreatedAtDesc(account); + for (int iter = 0; iter < open.size(); iter++) { + Invoice bill = open.get(iter); + if (bill.getMembershipId().equals(row.getId()) && OPEN.equals(bill.getStatus())) { + bill.setStatus(VOID); + bill.setFailure("Cancelled before it was paid"); + release(bill); + } + } + } else { + row.setCancelAtPeriodEnd(!row.isCancelAtPeriodEnd()); + } + row.setUpdatedAt(Instant.now()); + return toDto(row); + } + + private Membership owned(String account, String id) { + Membership row = id == null || id.length() > 32 ? null + : memberships.findFirstByIdAndAccount(id, account); + if (row == null || !LIVE.contains(row.getState())) { + throw new ResponseStatusException(404, "No such membership"); + } + return row; + } + + // ------------------------------------------------------------- invoices + + @Transactional(readOnly = true) + public List invoices(String account) { + return toDtos(invoices.findByAccountOrderByCreatedAtDesc(account)); + } + + /// Every invoice, or those in one state, for the owner. + @Transactional(readOnly = true) + public List all(String status) { + return toDtos(status == null || status.length() == 0 ? invoices.findAllByOrderByCreatedAtDesc() + : invoices.findByStatusOrderByCreatedAtDesc(status)); + } + + /// A member paying an invoice that is owed, with the card they choose. + public InvoiceDto pay(String account, String id, String methodId) throws IOException { + Invoice bill = owedBy(account, id); + if (bill == null) { + throw new ResponseStatusException(404, "No such invoice"); + } + String[] method = payments.choose(account, methodId); + if (Payments.CASH.equals(method[0])) { + throw new ResponseStatusException(400, "Pay cash at the front desk"); + } + reroute(bill.getId(), method[0]); + collect(bill.getId(), true); + return invoice(bill.getId()); + } + + @Transactional(rollbackFor = IOException.class) + void reroute(String id, String methodId) { + Invoice bill = invoices.findById(id); + if (!OPEN.equals(bill.getStatus()) && !FAILED.equals(bill.getStatus())) { + throw new ResponseStatusException(409, "That invoice is not owed"); + } + bill.setMethodId(methodId); + } + + /// Voids an invoice that is owed, for the owner: what was taken off it + /// in credit goes back to the member, and a membership waiting on it is + /// put right. + @Transactional(rollbackFor = IOException.class) + public InvoiceDto voidInvoice(String id, String reason) { + Invoice bill = id == null ? null : invoices.findById(id); + if (bill == null) { + throw new ResponseStatusException(404, "No such invoice"); + } + if (!OPEN.equals(bill.getStatus()) && !FAILED.equals(bill.getStatus())) { + throw new ResponseStatusException(409, "Only an invoice still owed can be voided"); + } + bill.setStatus(VOID); + bill.setFailure(reason == null || reason.trim().length() == 0 ? "Voided" + : cut(reason.trim(), 200)); + bill.setNextAttemptAt(NEVER); + release(bill); + Membership row = memberships.findById(bill.getMembershipId()); + if (row != null && (PAST_DUE.equals(row.getState()) || PENDING.equals(row.getState())) + && !owes(row)) { + row.setState(ACTIVE); + row.setUpdatedAt(Instant.now()); + } + return toDto(bill); + } + + /// Charges an invoice once. `interactive` is a member waiting on the + /// answer: a decline is then theirs to see, as a 402, rather than a + /// retry scheduled behind their back. + void collect(String invoiceId, boolean interactive) throws IOException { + String[] job = claim(invoiceId); + if (job == null) { + if (interactive) { + throw new ResponseStatusException(409, "That invoice is already being paid"); + } + return; + } + long amount = Long.parseLong(job[3]); + String reference; + try { + reference = amount <= 0 ? "credit" : payments.charge(invoiceId, job[0], job[1], amount, + job[2], job[4]); + } catch (PaymentDeclinedException declined) { + failed(invoiceId, declined.getMessage()); + if (interactive) { + throw new ResponseStatusException(402, declined.getMessage()); + } + return; + } catch (IOException unreachable) { + // Not a decline: the processor was not reached, or said nothing + // usable. The invoice goes back to owed without counting the + // attempt as a failure, and the next run tries it again. + reopen(invoiceId); + throw unreachable; + } + paid(invoiceId, reference); + } + + /// Claims an invoice for one attempt. + /// + /// @return the account, method, currency, amount due and description, + /// or null when it is not this caller's to charge + @Transactional(rollbackFor = IOException.class) + String[] claim(String id) { + if (invoices.claim(id) != 1) { + return null; + } + Invoice bill = invoices.findById(id); + return new String[] {bill.getAccount(), bill.getMethodId(), bill.getCurrency(), + String.valueOf(bill.getAmountCents() - bill.getCreditCents()), bill.getDescription()}; + } + + @Transactional(rollbackFor = IOException.class) + void paid(String id, String reference) throws IOException { + Instant now = Instant.now(); + Invoice bill = invoices.findById(id); + bill.setStatus(PAID); + bill.setChargeRef(reference); + bill.setPaidAt(now); + bill.setFailure(""); + bill.setNextAttemptAt(NEVER); + Membership row = memberships.findById(bill.getMembershipId()); + if (row != null && (PAST_DUE.equals(row.getState()) || PENDING.equals(row.getState())) + && !owes(row)) { + row.setState(ACTIVE); + row.setUpdatedAt(now); + } + referral(bill.getAccount(), now); + } + + @Transactional(rollbackFor = IOException.class) + void failed(String id, String why) { + Instant now = Instant.now(); + Invoice bill = invoices.findById(id); + int attempt = bill.getAttempts(); + bill.setStatus(FAILED); + bill.setFailure(cut(why == null ? "Declined" : why, 200)); + bill.setNextAttemptAt(attempt <= RETRY_DAYS.length + ? now.plusMillis(RETRY_DAYS[attempt - 1] * DAY) : NEVER); + Membership row = memberships.findById(bill.getMembershipId()); + if (row != null && attempt >= PAST_DUE_AFTER && ACTIVE.equals(row.getState())) { + row.setState(PAST_DUE); + row.setUpdatedAt(now); + } + } + + @Transactional(rollbackFor = IOException.class) + void reopen(String id) { + Invoice bill = invoices.findById(id); + bill.setStatus(bill.getAttempts() > 1 ? FAILED : OPEN); + bill.setAttempts(bill.getAttempts() - 1); + } + + private boolean owes(Membership row) { + List mine = invoices.findByAccountOrderByCreatedAtDesc(row.getAccount()); + for (int iter = 0; iter < mine.size(); iter++) { + Invoice bill = mine.get(iter); + if (bill.getMembershipId().equals(row.getId()) && (FAILED.equals(bill.getStatus()) + || OPEN.equals(bill.getStatus()) || CHARGING.equals(bill.getStatus()))) { + return true; + } + } + return false; + } + + /// The first payment of an account somebody referred earns that somebody + /// a credit, once. + private void referral(String account, Instant now) throws IOException { + Profile who = profiles.findById(account); + if (who == null || who.getReferredBy() == null || who.getReferredBy().length() == 0 + || invoices.countByAccountAndStatus(account, PAID) != 1) { + return; + } + String reason = "Referral: " + account; + if (profiles.findById(who.getReferredBy()) != null + && !credits.existsByAccountAndReason(who.getReferredBy(), reason)) { + give(who.getReferredBy(), REFERRAL_CENTS, reason, now); + } + } + + // ------------------------------------------------------------- jobs + + /// Renews what is due, starts and ends freezes, and retries what failed. + /// Each membership and invoice is its own transaction, so one that + /// fails is logged and left for the next run without holding up the rest. + @Scheduled(fixedDelay = 600000, initialDelay = 60000, lock = "pulse-billing") + public void run() { + Instant now = Instant.now(); + List due = renewals(now); + for (int iter = 0; iter < due.size(); iter++) { + try { + String bill = renew(due.get(iter), now); + if (bill != null) { + collect(bill, false); + } + } catch (IOException | RuntimeException err) { + System.err.println("[billing] " + err); + } + } + List retries = retries(now); + for (int iter = 0; iter < retries.size(); iter++) { + try { + collect(retries.get(iter), false); + } catch (IOException | RuntimeException err) { + System.err.println("[billing] " + err); + } + } + } + + /// Memberships whose period has ended, and those whose freeze starts or + /// ends today, by id. + @Transactional(readOnly = true) + List renewals(Instant now) { + List out = new ArrayList(); + List rows = memberships.findByStateIn(Arrays.asList(ACTIVE, FROZEN, PAST_DUE)); + for (int iter = 0; iter < rows.size(); iter++) { + Membership row = rows.get(iter); + boolean freezeDue = !row.getFrozenUntil().equals(Instant.EPOCH) + && (ACTIVE.equals(row.getState()) && !row.getFrozenFrom().isAfter(now) + || FROZEN.equals(row.getState()) && !row.getFrozenUntil().isAfter(now)); + if (freezeDue || !row.getCurrentPeriodEnd().isAfter(now)) { + out.add(row.getId()); + } + } + return out; + } + + @Transactional(readOnly = true) + List retries(Instant now) { + List rows = invoices.findByStatusInAndNextAttemptAtLessThanEqual( + Arrays.asList(FAILED), now); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(rows.get(iter).getId()); + } + return out; + } + + /// Moves one membership on. + /// + /// @return the id of an invoice to charge, or null + @Transactional(rollbackFor = IOException.class) + String renew(String id, Instant now) throws IOException { + Membership row = memberships.findById(id); + if (row == null) { + return null; + } + if (ACTIVE.equals(row.getState()) && !row.getFrozenUntil().equals(Instant.EPOCH) + && !row.getFrozenFrom().isAfter(now)) { + row.setState(FROZEN); + row.setUpdatedAt(now); + } + if (FROZEN.equals(row.getState()) && !row.getFrozenUntil().isAfter(now)) { + thaw(row, now); + } + if (row.getCurrentPeriodEnd().isAfter(now) || FROZEN.equals(row.getState())) { + // A frozen period is extended when the freeze ends, not billed. + return null; + } + MembershipPlan plan = plans.findById(row.getPlanId()); + if (plan == null || Household.counted(plan)) { + row.setState(EXPIRED); + row.setUpdatedAt(now); + return null; + } + if (row.isCancelAtPeriodEnd() || PAST_DUE.equals(row.getState())) { + // A member who has not paid the last period is not billed a + // new one on top: the membership lapses, the debt stays owed. + row.setState(row.isCancelAtPeriodEnd() ? CANCELLED : EXPIRED); + row.setUpdatedAt(now); + return null; + } + Instant start = row.getCurrentPeriodEnd(); + if (invoices.existsByMembershipIdAndPeriodStart(row.getId(), start)) { + return null; + } + Member member = members.findById(row.getMemberId()); + String method = payments.defaultMethod(row.getAccount()); + Instant end = plusMonths(start, plan.getPeriodMonths()); + row.setCurrentPeriodEnd(end); + row.setUpdatedAt(now); + Invoice bill = invoice(row, plan, member, start, end, method == null ? "" : method); + return Payments.CASH.equals(bill.getMethodId()) ? null : bill.getId(); + } + + // ------------------------------------------------------------- internals + + private Invoice invoice(Membership row, MembershipPlan plan, Member member, Instant start, + Instant end, String methodId) throws IOException { + Invoice bill = new Invoice(); + bill.setId(Ids.next()); + bill.setAccount(row.getAccount()); + bill.setMembershipId(row.getId()); + bill.setMemberId(row.getMemberId()); + bill.setDescription(cut(plan.getName() + " - " + (member == null ? "" : member.getName()), + 200)); + bill.setAmountCents(plan.getPriceCents()); + bill.setCurrency(plan.getCurrency()); + bill.setStatus(OPEN); + bill.setPeriodStart(start); + bill.setPeriodEnd(end); + bill.setMethodId(methodId); + bill.setNextAttemptAt(NEVER); + bill.setCreatedAt(Instant.now()); + invoices.insert(bill); + apply(bill); + return bill; + } + + /// Takes the account's unspent credit off an invoice, oldest first. A + /// credit larger than what is left is split, so the rest stays to spend. + private void apply(Invoice bill) throws IOException { + List open = credits.findByAccountAndInvoiceIdOrderByCreatedAtAsc(bill.getAccount(), + ""); + long left = bill.getAmountCents(); + long used = 0; + for (int iter = 0; iter < open.size() && left > 0; iter++) { + Credit row = open.get(iter); + if (row.getAmountCents() > left) { + Credit rest = new Credit(); + rest.setId(Ids.next()); + rest.setAccount(row.getAccount()); + rest.setAmountCents(row.getAmountCents() - left); + rest.setReason(row.getReason()); + rest.setCreatedAt(row.getCreatedAt()); + credits.insert(rest); + row.setAmountCents(left); + } + row.setInvoiceId(bill.getId()); + left -= row.getAmountCents(); + used += row.getAmountCents(); + } + bill.setCreditCents(used); + } + + /// Gives back the credit an invoice that will not be paid had taken. + private void release(Invoice bill) { + List used = credits.findByAccountAndInvoiceIdOrderByCreatedAtAsc(bill.getAccount(), + bill.getId()); + for (int iter = 0; iter < used.size(); iter++) { + used.get(iter).setInvoiceId(""); + } + bill.setCreditCents(0); + } + + private void give(String account, long cents, String reason, Instant now) throws IOException { + Credit row = new Credit(); + row.setId(Ids.next()); + row.setAccount(account); + row.setAmountCents(cents); + row.setReason(cut(reason, 200)); + row.setCreatedAt(now); + credits.insert(row); + } + + public MembershipDto toDto(Membership row) { + MembershipPlan plan = plans.findById(row.getPlanId()); + Member member = members.findById(row.getMemberId()); + return new MembershipDto(row.getId(), row.getMemberId(), + member == null ? "" : member.getName(), row.getPlanId(), + plan == null ? "" : plan.getName(), plan == null ? "" : plan.getKind(), + row.getState(), row.getStartedAt().toEpochMilli(), + row.getCurrentPeriodEnd().toEpochMilli(), row.getCreditsLeft(), + row.getFrozenFrom().toEpochMilli(), row.getFrozenUntil().toEpochMilli(), + row.isCancelAtPeriodEnd()); + } + + private List toDtos(List rows) { + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + public InvoiceDto toDto(Invoice row) { + Member member = members.findById(row.getMemberId()); + String label = Payments.CASH.equals(row.getMethodId()) ? "Cash" + : row.getMethodId().length() == 0 ? "" : "Card"; + return new InvoiceDto(row.getId(), member == null ? "" : member.getName(), + row.getDescription(), row.getAmountCents(), row.getCreditCents(), + row.getCurrency(), row.getStatus(), row.getPeriodStart().toEpochMilli(), + row.getPeriodEnd().toEpochMilli(), row.getAttempts(), + NEVER.equals(row.getNextAttemptAt()) ? 0 : row.getNextAttemptAt().toEpochMilli(), + label, row.getFailure(), row.getCreatedAt().toEpochMilli(), + row.getPaidAt().toEpochMilli()); + } + + /// The same instant some calendar months on, in UTC; the 31st of a month + /// lands on the last day of a shorter one. + public static Instant plusMonths(Instant at, int months) { + return at.atZone(ZoneOffset.UTC).plusMonths(months).toInstant(); + } + + /// A `yyyy-MM-dd` from the client, or 400. + public static LocalDate date(String text) { + try { + return LocalDate.parse(text); + } catch (RuntimeException bad) { + throw new ResponseStatusException(400, "Dates are written yyyy-mm-dd"); + } + } + + private static Instant start(LocalDate day) { + return Instant.ofEpochMilli(day.toEpochDay() * DAY); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/MembershipEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/MembershipEndpoint.java new file mode 100644 index 00000000000..0ebf2ccd53f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/billing/MembershipEndpoint.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.billing; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.FreezeDto; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.JoinDto; +import com.codenameone.examples.pulse.api.MembershipApiServer; +import com.codenameone.examples.pulse.api.MembershipDto; +import com.codenameone.examples.pulse.api.PayInvoiceDto; +import com.codenameone.examples.pulse.api.PlanDto; + +import java.util.List; + +/// The server's half of `MembershipApi`: a household's plans and bills. +@Component +public class MembershipEndpoint implements MembershipApiServer { + private final Billing billing; + + public MembershipEndpoint(Billing billing) { + this.billing = billing; + } + + @Override + public List plans() throws Exception { + return billing.plans(false); + } + + @Override + public List memberships() throws Exception { + return billing.memberships(Caller.name()); + } + + @Override + public MembershipDto join(JoinDto body) throws Exception { + return billing.join(Caller.name(), body); + } + + @Override + public MembershipDto freeze(String id, FreezeDto body) throws Exception { + return billing.freeze(Caller.name(), id, body); + } + + @Override + public MembershipDto unfreeze(String id) throws Exception { + return billing.unfreeze(Caller.name(), id); + } + + @Override + public MembershipDto cancel(String id) throws Exception { + return billing.cancel(Caller.name(), id); + } + + @Override + public List invoices() throws Exception { + return billing.invoices(Caller.name()); + } + + @Override + public InvoiceDto pay(String id, PayInvoiceDto body) throws Exception { + return billing.pay(Caller.name(), id, body == null ? null : body.methodId()); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/Desk.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/Desk.java new file mode 100644 index 00000000000..2baf40df6bb --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/Desk.java @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.desk; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.Text; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.CheckInRequestDto; +import com.codenameone.examples.pulse.api.CheckInResultDto; +import com.codenameone.examples.pulse.api.DeskDayDto; +import com.codenameone.examples.pulse.api.MemberSummaryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.WalkInDto; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.CheckIn; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.WalkIn; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.WalkInRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Household; +import com.codenameone.examples.pulse.studio.Passes; +import com.codenameone.examples.pulse.studio.Studio; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +/// The front desk and the door: letting people in, finding a member, and +/// taking a walk-in's money. +/// +/// A check-in always answers -- admitted, or refused with the reason the desk +/// reads out -- and never fails for a member who is simply not allowed in: +/// a kiosk turns that answer into a green or a red screen, and a refusal is +/// as much an answer as an admission. +@Component +public class Desk { + /// A booking counts as this visit's from this long before its class... + static final long EARLY_MILLIS = 60L * 60000L; + /// ...to this long after it started. + static final long LATE_MILLIS = 30L * 60000L; + static final String[] WALK_IN_KINDS = {"GUEST", "DAY_PASS", "TRIAL"}; + static final String[] PAID_BY = {"CASH", "CARD", "NONE"}; + /// The most a walk-in is charged at the desk, in cents. A typo guard. + static final long MOST_CENTS = 50000L; + + private final Passes passes; + private final MemberRepository members; + private final Household household; + private final MembershipPlanRepository plans; + private final BookingRepository bookings; + private final ClassSessionRepository sessions; + private final ClassTypeRepository types; + private final CheckInRepository checkIns; + private final WalkInRepository walkIns; + private final Accounts accounts; + private final Studio studio; + private final LiveHub live; + + public Desk(Passes passes, MemberRepository members, Household household, + MembershipPlanRepository plans, BookingRepository bookings, + ClassSessionRepository sessions, ClassTypeRepository types, + CheckInRepository checkIns, WalkInRepository walkIns, Accounts accounts, + Studio studio, LiveHub live) { + this.passes = passes; + this.members = members; + this.household = household; + this.plans = plans; + this.bookings = bookings; + this.sessions = sessions; + this.types = types; + this.checkIns = checkIns; + this.walkIns = walkIns; + this.accounts = accounts; + this.studio = studio; + this.live = live; + } + + /// Lets someone in, or says why not. `staff` is who is at the desk, or + /// the kiosk's account. + public CheckInResultDto checkIn(String staff, CheckInRequestDto request) throws IOException { + if (request == null || request.locationId() == null + || request.locationId().length() == 0) { + throw new ResponseStatusException(400, "Say which door"); + } + CheckInResultDto result; + try { + result = admit(staff, request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (RuntimeException raced) { + // The same code shown at two doors at once: both found it unused + // and the second insert of it failed. That is a replay. + result = refused(REPLAYED, null); + } + live.checkedIn(request.locationId(), result.memberName(), result.admitted()); + return result; + } + + private static final String REPLAYED = "That pass was already used. Refresh it and try again"; + + @Transactional(rollbackFor = IOException.class) + CheckInResultDto admit(String staff, CheckInRequestDto request) throws IOException { + Instant now = Instant.now(); + String method; + String memberId; + if (request.code() != null && request.code().length() > 0) { + method = "QR"; + memberId = passes.redeem(request.code().trim(), now.toEpochMilli()); + if (memberId == null) { + return refused("That pass is not valid. Refresh it and try again", null); + } + if (memberId.length() == 0) { + return refused(REPLAYED, null); + } + } else if (request.nfcId() != null && request.nfcId().length() > 0) { + method = "NFC"; + memberId = passes.byNfc(request.nfcId().trim()); + if (memberId == null) { + return refused("That card is not one of ours", null); + } + } else { + // Looked up by name at the desk, which only staff can do; a + // kiosk has no lookup. + method = "STAFF"; + memberId = request.memberId(); + } + Member member = memberId == null || memberId.length() > 32 ? null + : members.findById(memberId); + if (member == null) { + return refused("No such member", null); + } + if (!accounts.enabled(member.getAccount())) { + return refused("This account is on hold. Please see the desk", member); + } + if (!member.getWaiverAt().isAfter(Instant.EPOCH)) { + return refused("The waiver has not been signed", member); + } + Membership membership = household.current(member.getId()); + MembershipPlan plan = membership == null ? null : plans.findById(membership.getPlanId()); + if (membership == null) { + return refused("No membership. A day pass is available at the desk", member); + } + if ("PAST_DUE".equals(membership.getState())) { + return refused("A payment is overdue. Please see the desk", member); + } + if ("FROZEN".equals(membership.getState()) && membership.getFrozenUntil().isAfter(now)) { + return refused("The membership is frozen", member); + } + // The class they are here for, if they booked one: it is marked as + // attended, and a pack's class was already spent on booking it. + List booked = bookings.bookedBetween(member.getId(), + now.minusMillis(LATE_MILLIS), now.plusMillis(EARLY_MILLIS)); + Booking booking = null; + ClassSession session = null; + for (int iter = 0; iter < booked.size() && booking == null; iter++) { + ClassSession candidate = sessions.findById(booked.get(iter).getSessionId()); + if (candidate != null && candidate.getLocationId().equals(request.locationId())) { + booking = booked.get(iter); + session = candidate; + } + } + if (booking == null && plan != null && Household.counted(plan)) { + return refused("A class pack is for booked classes. Book one first", member); + } + if (booking != null) { + booking.setState(Bookings.CHECKED_IN); + booking.setCheckedInAt(now); + booking.setUpdatedAt(now); + } + CheckIn row = new CheckIn(); + row.setId(Ids.next()); + row.setMemberId(member.getId()); + row.setAccount(member.getAccount()); + row.setLocationId(request.locationId()); + row.setMethod(method); + row.setSessionId(session == null ? "" : session.getId()); + row.setAt(now); + row.setStaff(staff); + checkIns.insert(row); + ClassType type = session == null ? null : types.findById(session.getClassTypeId()); + return new CheckInResultDto(true, "", member.getId(), member.getName(), + Accounts.age(member.getBirthDate(), LocalDate.now()), + plan == null ? "" : plan.getName(), membership.getState(), + type == null ? "" : type.getName(), + session == null ? 0L : session.getStartsAt().toEpochMilli(), + (int) checkIns.countByMemberIdAndAtGreaterThanEqual(member.getId(), + monthStart(now))); + } + + private CheckInResultDto refused(String reason, Member member) { + Membership membership = member == null ? null : household.current(member.getId()); + MembershipPlan plan = membership == null ? null : plans.findById(membership.getPlanId()); + return new CheckInResultDto(false, reason, member == null ? "" : member.getId(), + member == null ? "" : member.getName(), + member == null ? 0 : Accounts.age(member.getBirthDate(), LocalDate.now()), + plan == null ? "" : plan.getName(), + membership == null ? "NONE" : membership.getState(), "", 0L, 0); + } + + /// Members whose name or account contains `query`, for the desk. + @Transactional(readOnly = true) + public List find(String query) { + String text = query == null ? "" : query.trim(); + if (text.length() < 2) { + throw new ResponseStatusException(400, "Type at least two letters"); + } + if (text.length() > 60 || text.indexOf('%') >= 0 || text.indexOf('_') >= 0) { + throw new ResponseStatusException(400, "That is not a name"); + } + List rows = members.search("%" + text + "%"); + List out = new ArrayList(); + LocalDate today = LocalDate.now(); + for (int iter = 0; iter < rows.size() && out.size() < 25; iter++) { + Member row = rows.get(iter); + Membership membership = household.current(row.getId()); + MembershipPlan plan = membership == null ? null + : plans.findById(membership.getPlanId()); + Instant last = checkIns.lastVisit(row.getAccount()); + out.add(new MemberSummaryDto(row.getId(), row.getAccount(), row.getName(), + Accounts.age(row.getBirthDate(), today), row.getRelation(), + membership == null ? "NONE" : membership.getState(), + plan == null ? "" : plan.getName(), last == null ? 0L : last.toEpochMilli())); + } + return out; + } + + /// Records someone who came in without a membership: a guest, a day + /// pass or a free trial. Their details are what the owner's campaigns + /// follow up on; the money, if any, was taken at the desk. + @Transactional(rollbackFor = IOException.class) + public WalkInDto walkIn(String staff, WalkInDto walkIn) throws IOException { + if (walkIn == null) { + throw new ResponseStatusException(400, "Nothing to record"); + } + String name = Text.required(walkIn.name(), 120, "their name"); + String email = walkIn.email() == null || walkIn.email().trim().length() == 0 ? "" + : Accounts.email(walkIn.email()); + String phone = Text.optional(walkIn.phone(), 32, "their phone"); + String kind = Text.oneOf(walkIn.kind(), WALK_IN_KINDS, WALK_IN_KINDS[1], "the kind"); + String paidBy = Text.oneOf(walkIn.paidBy(), PAID_BY, PAID_BY[0], "how it was paid"); + if (walkIn.amountCents() < 0 || walkIn.amountCents() > MOST_CENTS) { + throw new ResponseStatusException(400, "That amount is not a day pass"); + } + if ("NONE".equals(paidBy) != (walkIn.amountCents() == 0)) { + throw new ResponseStatusException(400, "Say how it was paid, or that it was free"); + } + String location = Text.required(walkIn.locationId(), 32, "the location"); + WalkIn row = new WalkIn(); + row.setId(Ids.next()); + row.setLocationId(location); + row.setName(name); + row.setEmail(email); + row.setPhone(phone); + row.setKind(kind); + row.setAmountCents(walkIn.amountCents()); + row.setPaidBy(paidBy); + row.setStaff(staff); + row.setAt(Instant.now()); + walkIns.insert(row); + return toDto(row); + } + + static WalkInDto toDto(WalkIn row) { + return new WalkInDto(row.getId(), row.getLocationId(), row.getName(), row.getEmail(), + row.getPhone(), row.getKind(), row.getAmountCents(), row.getPaidBy(), + row.getAt().toEpochMilli()); + } + + /// The desk's day at a location: who came, what was taken, and what is + /// starting soon. + @Transactional(readOnly = true) + public DeskDayDto today(String staff, String locationId) throws IOException { + if (locationId == null || locationId.length() == 0) { + throw new ResponseStatusException(400, "Say which location"); + } + Instant now = Instant.now(); + // The location's day, not UTC's: a studio in New York would otherwise + // start a new day at seven or eight in the evening. + ZoneId zone = studio.zone(locationId); + Instant dayStart = LocalDate.now(zone).atStartOfDay(zone).toInstant(); + List walked = walkIns.findByLocationIdAndAtGreaterThanEqualOrderByAtDesc( + locationId, dayStart); + long cents = 0L; + for (int iter = 0; iter < walked.size(); iter++) { + cents += walked.get(iter).getAmountCents(); + } + List upcoming = studio.describe(staff, + sessions.findByLocationIdAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + locationId, now.minusMillis(LATE_MILLIS), now.plusMillis(3L * 3600000L))); + List came = checkIns.findByLocationIdAndAtGreaterThanEqualOrderByAtDesc( + locationId, dayStart); + List recent = new ArrayList(); + LocalDate today = LocalDate.now(zone); + for (int iter = 0; iter < came.size() && recent.size() < 12; iter++) { + CheckIn row = came.get(iter); + Member member = members.findById(row.getMemberId()); + if (member == null) { + continue; + } + Membership membership = household.current(member.getId()); + MembershipPlan plan = membership == null ? null + : plans.findById(membership.getPlanId()); + ClassSession session = row.getSessionId().length() == 0 ? null + : sessions.findById(row.getSessionId()); + ClassType type = session == null ? null : types.findById(session.getClassTypeId()); + recent.add(new CheckInResultDto(true, "", member.getId(), member.getName(), + Accounts.age(member.getBirthDate(), today), + plan == null ? "" : plan.getName(), + membership == null ? "NONE" : membership.getState(), + type == null ? "" : type.getName(), + session == null ? 0L : session.getStartsAt().toEpochMilli(), 0)); + } + return new DeskDayDto(came.size(), walked.size(), cents, upcoming, recent); + } + + /// Midnight UTC on the first of `now`'s month. + static Instant monthStart(Instant now) { + LocalDate day = LocalDate.ofEpochDay(now.toEpochMilli() / Days.MILLIS); + return Instant.ofEpochMilli(day.withDayOfMonth(1).toEpochDay() * Days.MILLIS); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/DeskEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/DeskEndpoint.java new file mode 100644 index 00000000000..25497083627 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/desk/DeskEndpoint.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.desk; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.CheckInRequestDto; +import com.codenameone.examples.pulse.api.CheckInResultDto; +import com.codenameone.examples.pulse.api.DeskApiServer; +import com.codenameone.examples.pulse.api.DeskDayDto; +import com.codenameone.examples.pulse.api.MemberSummaryDto; +import com.codenameone.examples.pulse.api.WalkInDto; + +import java.util.List; + +/// The server's half of `DeskApi`, for the front desk, the door kiosk and +/// the owner. Guarded by path in the filter chain and by role here. +@Component +@PreAuthorize("hasAnyRole('DESK', 'OWNER')") +public class DeskEndpoint implements DeskApiServer { + private final Desk desk; + + public DeskEndpoint(Desk desk) { + this.desk = desk; + } + + @Override + public CheckInResultDto checkIn(CheckInRequestDto request) throws Exception { + return desk.checkIn(Caller.name(), request); + } + + @Override + public List find(String q) throws Exception { + Caller.name(); + return desk.find(q); + } + + @Override + public WalkInDto walkIn(WalkInDto walkIn) throws Exception { + return desk.walkIn(Caller.name(), walkIn); + } + + @Override + public DeskDayDto today(String location) throws Exception { + return desk.today(Caller.name(), location); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/AwayPeriod.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/AwayPeriod.java new file mode 100644 index 00000000000..818f8ed7e7b --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/AwayPeriod.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; +import java.time.LocalDate; + +/// A member saying they will not be in: travelling, injured. No nudge reaches +/// them meanwhile, and with `freeze_billing` their membership is frozen for +/// the days it covers. +@Entity +public class AwayPeriod { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false) + private LocalDate startsOn = LocalDate.of(1970, 1, 1); + + @Column(nullable = false) + private LocalDate endsOn = LocalDate.of(1970, 1, 1); + + @Column(nullable = false, type = "VARCHAR(16)") + private String reason = ""; + + @Column(nullable = false) + private boolean freezeBilling; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public AwayPeriod() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public LocalDate getStartsOn() { + return startsOn; + } + + public void setStartsOn(LocalDate startsOn) { + this.startsOn = startsOn; + } + + public LocalDate getEndsOn() { + return endsOn; + } + + public void setEndsOn(LocalDate endsOn) { + this.endsOn = endsOn; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public boolean isFreezeBilling() { + return freezeBilling; + } + + public void setFreezeBilling(boolean freezeBilling) { + this.freezeBilling = freezeBilling; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Booking.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Booking.java new file mode 100644 index 00000000000..5be3adb00a7 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Booking.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A place in a class, or in the queue for one. `queue_position` orders the +/// waitlist; it is 0 for a place. +@Entity +public class Booking { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String memberId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String state = ""; + + @Column(nullable = false) + private long queuePosition; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant checkedInAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String invitedBy = ""; + + public Booking() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getMemberId() { + return memberId; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public long getQueuePosition() { + return queuePosition; + } + + public void setQueuePosition(long queuePosition) { + this.queuePosition = queuePosition; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public Instant getCheckedInAt() { + return checkedInAt; + } + + public void setCheckedInAt(Instant checkedInAt) { + this.checkedInAt = checkedInAt; + } + + public String getInvitedBy() { + return invitedBy; + } + + public void setInvitedBy(String invitedBy) { + this.invitedBy = invitedBy; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Campaign.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Campaign.java new file mode 100644 index 00000000000..8310fec8d84 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Campaign.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A campaign an owner sent, and how it did. +@Entity +public class Campaign { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String body = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String createdBy = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private int sent; + + @Column(nullable = false) + private int opened; + + @Column(nullable = false) + private int booked; + + public Campaign() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public int getSent() { + return sent; + } + + public void setSent(int sent) { + this.sent = sent; + } + + public int getOpened() { + return opened; + } + + public void setOpened(int opened) { + this.opened = opened; + } + + public int getBooked() { + return booked; + } + + public void setBooked(int booked) { + this.booked = booked; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/CheckIn.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/CheckIn.java new file mode 100644 index 00000000000..7952d8887bc --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/CheckIn.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Coming through the door. Every check-in is kept: it is what attendance, +/// streaks and the "we miss you" nudge are worked out from. +@Entity +public class CheckIn { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String memberId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String method = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false) + private Instant at = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String staff = ""; + + public CheckIn() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMemberId() { + return memberId; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public Instant getAt() { + return at; + } + + public void setAt(Instant at) { + this.at = at; + } + + public String getStaff() { + return staff; + } + + public void setStaff(String staff) { + this.staff = staff; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassSession.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassSession.java new file mode 100644 index 00000000000..540c273aa6c --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassSession.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One class on the timetable. `booked` counts the places taken and is only +/// ever changed by a conditional update against `capacity`, which is what +/// makes overbooking impossible whatever arrives at once. +@Entity +public class ClassSession { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String classTypeId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String roomId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String trainer = ""; + + @Column(nullable = false) + private Instant startsAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant endsAt = Instant.EPOCH; + + @Column(nullable = false) + private int capacity; + + @Column(nullable = false) + private int booked; + + @Column(nullable = false) + private int waitlisted; + + @Column(nullable = false, type = "VARCHAR(16)") + private String state = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + public ClassSession() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getClassTypeId() { + return classTypeId; + } + + public void setClassTypeId(String classTypeId) { + this.classTypeId = classTypeId; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getRoomId() { + return roomId; + } + + public void setRoomId(String roomId) { + this.roomId = roomId; + } + + public String getTrainer() { + return trainer; + } + + public void setTrainer(String trainer) { + this.trainer = trainer; + } + + public Instant getStartsAt() { + return startsAt; + } + + public void setStartsAt(Instant startsAt) { + this.startsAt = startsAt; + } + + public Instant getEndsAt() { + return endsAt; + } + + public void setEndsAt(Instant endsAt) { + this.endsAt = endsAt; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + public int getBooked() { + return booked; + } + + public void setBooked(int booked) { + this.booked = booked; + } + + public int getWaitlisted() { + return waitlisted; + } + + public void setWaitlisted(int waitlisted) { + this.waitlisted = waitlisted; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassType.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassType.java new file mode 100644 index 00000000000..e1418845134 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClassType.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A kind of class. Ages are inclusive, in whole years on the day of the +/// class; 0 and 120 mean no bound. +@Entity +public class ClassType { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String description = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String category = ""; + + @Column(nullable = false) + private int intensity; + + @Column(nullable = false) + private int minAge; + + @Column(nullable = false) + private int maxAge; + + @Column(nullable = false) + private int capacity; + + @Column(nullable = false) + private int minutes; + + @Column(nullable = false, type = "VARCHAR(8)") + private String color = ""; + + @Column(nullable = false) + private boolean active; + + public ClassType() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public int getIntensity() { + return intensity; + } + + public void setIntensity(int intensity) { + this.intensity = intensity; + } + + public int getMinAge() { + return minAge; + } + + public void setMinAge(int minAge) { + this.minAge = minAge; + } + + public int getMaxAge() { + return maxAge; + } + + public void setMaxAge(int maxAge) { + this.maxAge = maxAge; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + public int getMinutes() { + return minutes; + } + + public void setMinutes(int minutes) { + this.minutes = minutes; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClosedAccount.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClosedAccount.java new file mode 100644 index 00000000000..a66368df6a2 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ClosedAccount.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// What is left of an account that was closed: the hash of its e-mail, kept so +/// that the address cannot be registered again. +@Entity +public class ClosedAccount { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String usernameHash = ""; + + @Column(nullable = false) + private Instant closedAt = Instant.EPOCH; + + public ClosedAccount() { + } + + public String getUsernameHash() { + return usernameHash; + } + + public void setUsernameHash(String usernameHash) { + this.usernameHash = usernameHash; + } + + public Instant getClosedAt() { + return closedAt; + } + + public void setClosedAt(Instant closedAt) { + this.closedAt = closedAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Credit.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Credit.java new file mode 100644 index 00000000000..bd6c9defe59 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Credit.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Money owed to an account: a referral reward, a goodwill credit. Taken off +/// the next invoice. +@Entity +public class Credit { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(32)") + private String invoiceId = ""; + + public Credit() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getInvoiceId() { + return invoiceId; + } + + public void setInvoiceId(String invoiceId) { + this.invoiceId = invoiceId; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Friend.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Friend.java new file mode 100644 index 00000000000..8e245af925f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Friend.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Two accounts that train together. A request is one row from the asker; it +/// is accepted by writing the row back the other way. +@Entity +public class Friend { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String friend = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String state = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Friend() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getFriend() { + return friend; + } + + public void setFriend(String friend) { + this.friend = friend; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Invoice.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Invoice.java new file mode 100644 index 00000000000..e78288b7319 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Invoice.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A bill. The key it is charged under is its id, so a charge retried after a +/// timeout is the same charge at the processor. A failed charge is tried again +/// at `next_attempt_at`, on the dunning ladder; `attempts` counts the tries. +@Entity +public class Invoice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String membershipId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String memberId = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String description = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false) + private long creditCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private Instant periodStart = Instant.EPOCH; + + @Column(nullable = false) + private Instant periodEnd = Instant.EPOCH; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private Instant nextAttemptAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(64)") + private String chargeRef = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String methodId = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String failure = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant paidAt = Instant.EPOCH; + + public Invoice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getMembershipId() { + return membershipId; + } + + public void setMembershipId(String membershipId) { + this.membershipId = membershipId; + } + + public String getMemberId() { + return memberId; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public long getCreditCents() { + return creditCents; + } + + public void setCreditCents(long creditCents) { + this.creditCents = creditCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getPeriodStart() { + return periodStart; + } + + public void setPeriodStart(Instant periodStart) { + this.periodStart = periodStart; + } + + public Instant getPeriodEnd() { + return periodEnd; + } + + public void setPeriodEnd(Instant periodEnd) { + this.periodEnd = periodEnd; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public Instant getNextAttemptAt() { + return nextAttemptAt; + } + + public void setNextAttemptAt(Instant nextAttemptAt) { + this.nextAttemptAt = nextAttemptAt; + } + + public String getChargeRef() { + return chargeRef; + } + + public void setChargeRef(String chargeRef) { + this.chargeRef = chargeRef; + } + + public String getMethodId() { + return methodId; + } + + public void setMethodId(String methodId) { + this.methodId = methodId; + } + + public String getFailure() { + return failure; + } + + public void setFailure(String failure) { + this.failure = failure; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getPaidAt() { + return paidAt; + } + + public void setPaidAt(Instant paidAt) { + this.paidAt = paidAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Kudos.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Kudos.java new file mode 100644 index 00000000000..8c18f6faaa3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Kudos.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A row of `kudos`. +@Entity +public class Kudos { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String fromAccount = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String toAccount = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String workoutId = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Kudos() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFromAccount() { + return fromAccount; + } + + public void setFromAccount(String fromAccount) { + this.fromAccount = fromAccount; + } + + public String getToAccount() { + return toAccount; + } + + public void setToAccount(String toAccount) { + this.toAccount = toAccount; + } + + public String getWorkoutId() { + return workoutId; + } + + public void setWorkoutId(String workoutId) { + this.workoutId = workoutId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/LiveTicket.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/LiveTicket.java new file mode 100644 index 00000000000..278cb4888ed --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/LiveTicket.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A ticket for one connection to the live channel, as its hash. +@Entity +public class LiveTicket { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String ticketHash = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + public LiveTicket() { + } + + public String getTicketHash() { + return ticketHash; + } + + public void setTicketHash(String ticketHash) { + this.ticketHash = ticketHash; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Location.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Location.java new file mode 100644 index 00000000000..20a6e89974e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Location.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A site. Opening hours are minutes after midnight in the site's own zone. +@Entity +public class Location { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String address = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String timeZone = ""; + + @Column(nullable = false) + private int opensMinute; + + @Column(nullable = false) + private int closesMinute; + + public Location() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getTimeZone() { + return timeZone; + } + + public void setTimeZone(String timeZone) { + this.timeZone = timeZone; + } + + public int getOpensMinute() { + return opensMinute; + } + + public void setOpensMinute(int opensMinute) { + this.opensMinute = opensMinute; + } + + public int getClosesMinute() { + return closesMinute; + } + + public void setClosesMinute(int closesMinute) { + this.closesMinute = closesMinute; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Member.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Member.java new file mode 100644 index 00000000000..9eb62f1a625 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Member.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; +import java.time.LocalDate; + +/// A person who trains here. One account can hold several: the adult who signs +/// in is a member themselves (relation SELF), and books for their children +/// (relation CHILD), who have no account of their own. Not called `member`: +/// MySQL reserves the word. +@Entity(table = "studio_member") +public class Member { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false) + private LocalDate birthDate = LocalDate.of(1970, 1, 1); + + @Column(nullable = false, type = "VARCHAR(8)") + private String relation = ""; + + @Column(nullable = false) + private Instant waiverAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(500)") + private String medicalNote = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Member() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public void setBirthDate(LocalDate birthDate) { + this.birthDate = birthDate; + } + + public String getRelation() { + return relation; + } + + public void setRelation(String relation) { + this.relation = relation; + } + + public Instant getWaiverAt() { + return waiverAt; + } + + public void setWaiverAt(Instant waiverAt) { + this.waiverAt = waiverAt; + } + + public String getMedicalNote() { + return medicalNote; + } + + public void setMedicalNote(String medicalNote) { + this.medicalNote = medicalNote; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Membership.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Membership.java new file mode 100644 index 00000000000..b8fdfb03475 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Membership.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A member's plan. `current_period_end` is when the next invoice is raised; +/// a freeze moves it on by the frozen days. +@Entity +public class Membership { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String memberId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String planId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String state = ""; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant currentPeriodEnd = Instant.EPOCH; + + @Column(nullable = false) + private int creditsLeft; + + @Column(nullable = false) + private Instant frozenFrom = Instant.EPOCH; + + @Column(nullable = false) + private Instant frozenUntil = Instant.EPOCH; + + @Column(nullable = false) + private boolean cancelAtPeriodEnd; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Membership() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMemberId() { + return memberId; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getPlanId() { + return planId; + } + + public void setPlanId(String planId) { + this.planId = planId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public Instant getCurrentPeriodEnd() { + return currentPeriodEnd; + } + + public void setCurrentPeriodEnd(Instant currentPeriodEnd) { + this.currentPeriodEnd = currentPeriodEnd; + } + + public int getCreditsLeft() { + return creditsLeft; + } + + public void setCreditsLeft(int creditsLeft) { + this.creditsLeft = creditsLeft; + } + + public Instant getFrozenFrom() { + return frozenFrom; + } + + public void setFrozenFrom(Instant frozenFrom) { + this.frozenFrom = frozenFrom; + } + + public Instant getFrozenUntil() { + return frozenUntil; + } + + public void setFrozenUntil(Instant frozenUntil) { + this.frozenUntil = frozenUntil; + } + + public boolean isCancelAtPeriodEnd() { + return cancelAtPeriodEnd; + } + + public void setCancelAtPeriodEnd(boolean cancelAtPeriodEnd) { + this.cancelAtPeriodEnd = cancelAtPeriodEnd; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/MembershipPlan.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/MembershipPlan.java new file mode 100644 index 00000000000..5127b97d09a --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/MembershipPlan.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What can be bought. MONTHLY and ANNUAL renew; a CLASS_PACK is a number of +/// classes that lasts `period_months`; a DROP_IN is one class. +@Entity +public class MembershipPlan { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String description = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false) + private int periodMonths; + + @Column(nullable = false) + private int classCredits; + + @Column(nullable = false) + private int minAge; + + @Column(nullable = false) + private int maxAge; + + @Column(nullable = false) + private boolean offPeakOnly; + + @Column(nullable = false) + private boolean active; + + public MembershipPlan() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public int getPeriodMonths() { + return periodMonths; + } + + public void setPeriodMonths(int periodMonths) { + this.periodMonths = periodMonths; + } + + public int getClassCredits() { + return classCredits; + } + + public void setClassCredits(int classCredits) { + this.classCredits = classCredits; + } + + public int getMinAge() { + return minAge; + } + + public void setMinAge(int minAge) { + this.minAge = minAge; + } + + public int getMaxAge() { + return maxAge; + } + + public void setMaxAge(int maxAge) { + this.maxAge = maxAge; + } + + public boolean isOffPeakOnly() { + return offPeakOnly; + } + + public void setOffPeakOnly(boolean offPeakOnly) { + this.offPeakOnly = offPeakOnly; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ModerationEvent.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ModerationEvent.java new file mode 100644 index 00000000000..91d1ce16a5c --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/ModerationEvent.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One thing an admin did to an account, and why. Written once, never changed. +@Entity +public class ModerationEvent { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String actor = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + public ModerationEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Nudge.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Nudge.java new file mode 100644 index 00000000000..2b49ab50d0e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Nudge.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One message to one account, from a campaign or from the server's own jobs. +/// Kept so nobody is nudged twice for one thing, and so a booking that follows +/// can be credited to it. +@Entity +public class Nudge { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String campaignId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String body = ""; + + @Column(nullable = false) + private Instant sentAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant openedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant bookedAt = Instant.EPOCH; + + public Nudge() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getCampaignId() { + return campaignId; + } + + public void setCampaignId(String campaignId) { + this.campaignId = campaignId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public Instant getOpenedAt() { + return openedAt; + } + + public void setOpenedAt(Instant openedAt) { + this.openedAt = openedAt; + } + + public Instant getBookedAt() { + return bookedAt; + } + + public void setBookedAt(Instant bookedAt) { + this.bookedAt = bookedAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PassKey.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PassKey.java new file mode 100644 index 00000000000..ec813ce5894 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PassKey.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// The secret a member's pass codes are made from. The app computes a code +/// from it every 30 seconds with no network, so the door works when the +/// phone has no signal; the server keeps the codes it has accepted +/// (used_pass_code) so a screenshot of one gets in once. +@Entity +public class PassKey { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String memberId = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String secret = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String nfcId = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public PassKey() { + } + + public String getMemberId() { + return memberId; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getNfcId() { + return nfcId; + } + + public void setNfcId(String nfcId) { + this.nfcId = nfcId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentCustomer.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentCustomer.java new file mode 100644 index 00000000000..0d8c2fa1639 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentCustomer.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The payment provider's name for an account. +@Entity +public class PaymentCustomer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String reference = ""; + + public PaymentCustomer() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentMethod.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentMethod.java new file mode 100644 index 00000000000..9dbf60c1fc0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentMethod.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A saved card: what the provider calls it and what to show for it. The +/// number itself is never here. +@Entity +public class PaymentMethod { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String brand = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String last4 = ""; + + @Column(nullable = false) + private int expMonth; + + @Column(nullable = false) + private int expYear; + + @Column(nullable = false, type = "VARCHAR(64)") + private String token = ""; + + @Column(nullable = false) + private boolean preferred; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public PaymentMethod() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } + + public int getExpMonth() { + return expMonth; + } + + public void setExpMonth(int expMonth) { + this.expMonth = expMonth; + } + + public int getExpYear() { + return expYear; + } + + public void setExpYear(int expYear) { + this.expYear = expYear; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isPreferred() { + return preferred; + } + + public void setPreferred(boolean preferred) { + this.preferred = preferred; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentSetup.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentSetup.java new file mode 100644 index 00000000000..23944f64cc3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PaymentSetup.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A card being added on the provider's page, until the browser comes back. +@Entity +public class PaymentSetup { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reference = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean completed; + + public PaymentSetup() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PhoneCode.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PhoneCode.java new file mode 100644 index 00000000000..2489a6f783f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PhoneCode.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// The verification code an account was last sent, as its hash. +@Entity +public class PhoneCode { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String codeHash = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private Instant sentAt = Instant.EPOCH; + + /// The code sent before this one, while it is still good, or empty. + @Column(nullable = false, type = "VARCHAR(64)") + private String previousHash = ""; + + @Column(nullable = false) + private Instant previousExpiresAt = Instant.EPOCH; + + /// The guesses already taken at the code before, which keeps the budget + /// it had rather than starting a new one when another code is sent. + @Column(nullable = false) + private int previousAttempts; + + public PhoneCode() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public String getPreviousHash() { + return previousHash; + } + + public void setPreviousHash(String previousHash) { + this.previousHash = previousHash; + } + + public Instant getPreviousExpiresAt() { + return previousExpiresAt; + } + + public void setPreviousExpiresAt(Instant previousExpiresAt) { + this.previousExpiresAt = previousExpiresAt; + } + + public int getPreviousAttempts() { + return previousAttempts; + } + + public void setPreviousAttempts(int previousAttempts) { + this.previousAttempts = previousAttempts; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Preference.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Preference.java new file mode 100644 index 00000000000..ada0a32bf5a --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Preference.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How one person wants the app and their deliveries, when they have said. +@Entity +public class Preference { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String theme = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String units = ""; + + @Column(nullable = false) + private boolean notifyReceipts; + + @Column(nullable = false) + private boolean notifyPromotions; + + @Column(nullable = false) + private boolean notifyClasses; + + @Column(nullable = false) + private int quietFrom; + + @Column(nullable = false) + private int quietTo; + + public Preference() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTheme() { + return theme; + } + + public void setTheme(String theme) { + this.theme = theme; + } + + public String getUnits() { + return units; + } + + public void setUnits(String units) { + this.units = units; + } + + public boolean isNotifyReceipts() { + return notifyReceipts; + } + + public void setNotifyReceipts(boolean notifyReceipts) { + this.notifyReceipts = notifyReceipts; + } + + public boolean isNotifyPromotions() { + return notifyPromotions; + } + + public void setNotifyPromotions(boolean notifyPromotions) { + this.notifyPromotions = notifyPromotions; + } + + public boolean isNotifyClasses() { + return notifyClasses; + } + + public void setNotifyClasses(boolean notifyClasses) { + this.notifyClasses = notifyClasses; + } + + public int getQuietFrom() { + return quietFrom; + } + + public void setQuietFrom(int quietFrom) { + this.quietFrom = quietFrom; + } + + public int getQuietTo() { + return quietTo; + } + + public void setQuietTo(int quietTo) { + this.quietTo = quietTo; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Profile.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Profile.java new file mode 100644 index 00000000000..de626fa1172 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Profile.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An account's profile: who it is to the people it deliveries with. +/// +/// The sign-in -- password hash, roles, whether it is enabled -- is the backend's +/// own user store, under the same name. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String displayName = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String phone = ""; + + @Column(nullable = false) + private boolean phoneVerified; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(8)") + private String language = "en"; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false, type = "VARCHAR(255)") + private String flagReason = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String referredBy = ""; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public boolean isPhoneVerified() { + return phoneVerified; + } + + public void setPhoneVerified(boolean phoneVerified) { + this.phoneVerified = phoneVerified; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public String getFlagReason() { + return flagReason; + } + + public void setFlagReason(String flagReason) { + this.flagReason = flagReason; + } + + public String getReferredBy() { + return referredBy; + } + + public void setReferredBy(String referredBy) { + this.referredBy = referredBy; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PushDevice.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PushDevice.java new file mode 100644 index 00000000000..51fbe486e9a --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/PushDevice.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false, type = "VARCHAR(16)") + private String provider = ""; + + @Column(nullable = false, type = "VARCHAR(1024)") + private String token = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Room.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Room.java new file mode 100644 index 00000000000..0978e22acb3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Room.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A row of `room`. +@Entity +public class Room { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false) + private int capacity; + + public Room() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Shift.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Shift.java new file mode 100644 index 00000000000..61f061e8c1e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Shift.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A block of work at a site. A trainer asks for a swap; another takes it. +@Entity +public class Shift { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String staff = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String duty = ""; + + @Column(nullable = false) + private Instant startsAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant endsAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(16)") + private String state = ""; + + public Shift() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getStaff() { + return staff; + } + + public void setStaff(String staff) { + this.staff = staff; + } + + public String getDuty() { + return duty; + } + + public void setDuty(String duty) { + this.duty = duty; + } + + public Instant getStartsAt() { + return startsAt; + } + + public void setStartsAt(Instant startsAt) { + this.startsAt = startsAt; + } + + public Instant getEndsAt() { + return endsAt; + } + + public void setEndsAt(Instant endsAt) { + this.endsAt = endsAt; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Staff.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Staff.java new file mode 100644 index 00000000000..cf16548da3b --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Staff.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An account that works here. What it may do is its roles; this row is what +/// members are shown of a trainer, and when the certificate that lets them +/// teach runs out. +@Entity +public class Staff { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String bio = ""; + + @Column(nullable = false) + private Instant certifiedUntil = Instant.EPOCH; + + @Column(nullable = false) + private boolean active; + + public Staff() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public Instant getCertifiedUntil() { + return certifiedUntil; + } + + public void setCertifiedUntil(Instant certifiedUntil) { + this.certifiedUntil = certifiedUntil; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/UsedPassCode.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/UsedPassCode.java new file mode 100644 index 00000000000..1c59d7c532e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/UsedPassCode.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A row of `used_pass_code`. +@Entity +public class UsedPassCode { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String codeHash = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + public UsedPassCode() { + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/WalkIn.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/WalkIn.java new file mode 100644 index 00000000000..0b785106f7f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/WalkIn.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Someone at the desk without a membership: a guest, a day pass, a free +/// trial. A walk-in who joins keeps the row, pointing at their account. +@Entity +public class WalkIn { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String locationId = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String email = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String phone = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false, type = "VARCHAR(16)") + private String paidBy = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String staff = ""; + + @Column(nullable = false) + private Instant at = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String convertedAccount = ""; + + public WalkIn() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLocationId() { + return locationId; + } + + public void setLocationId(String locationId) { + this.locationId = locationId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getPaidBy() { + return paidBy; + } + + public void setPaidBy(String paidBy) { + this.paidBy = paidBy; + } + + public String getStaff() { + return staff; + } + + public void setStaff(String staff) { + this.staff = staff; + } + + public Instant getAt() { + return at; + } + + public void setAt(Instant at) { + this.at = at; + } + + public String getConvertedAccount() { + return convertedAccount; + } + + public void setConvertedAccount(String convertedAccount) { + this.convertedAccount = convertedAccount; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Workout.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Workout.java new file mode 100644 index 00000000000..8d144a6c1e0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/domain/Workout.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A workout, as the phone's health store or the watch recorded it. Zones are +/// minutes in each of the five heart rate zones, comma separated. +@Entity +public class Workout { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String account = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String activity = ""; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false) + private int minutes; + + @Column(nullable = false) + private int avgHr; + + @Column(nullable = false) + private int maxHr; + + @Column(nullable = false) + private int calories; + + @Column(nullable = false, type = "VARCHAR(64)") + private String zones = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String source = ""; + + @Column(nullable = false) + private boolean shared; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Workout() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getActivity() { + return activity; + } + + public void setActivity(String activity) { + this.activity = activity; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public int getMinutes() { + return minutes; + } + + public void setMinutes(int minutes) { + this.minutes = minutes; + } + + public int getAvgHr() { + return avgHr; + } + + public void setAvgHr(int avgHr) { + this.avgHr = avgHr; + } + + public int getMaxHr() { + return maxHr; + } + + public void setMaxHr(int maxHr) { + this.maxHr = maxHr; + } + + public int getCalories() { + return calories; + } + + public void setCalories(int calories) { + this.calories = calories; + } + + public String getZones() { + return zones; + } + + public void setZones(String zones) { + this.zones = zones; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Engagement.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Engagement.java new file mode 100644 index 00000000000..c8b5324fbca --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Engagement.java @@ -0,0 +1,466 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.AwayDto; +import com.codenameone.examples.pulse.api.HeartRateDto; +import com.codenameone.examples.pulse.api.NudgeDto; +import com.codenameone.examples.pulse.api.StatsDto; +import com.codenameone.examples.pulse.api.WorkoutDto; +import com.codenameone.examples.pulse.billing.Billing; +import com.codenameone.examples.pulse.domain.AwayPeriod; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.CheckIn; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.Nudge; +import com.codenameone.examples.pulse.domain.Workout; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.repository.AwayPeriodRepository; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CampaignRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.repository.WorkoutRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Household; +import com.codenameone.examples.pulse.studio.Studio; +import com.codenameone.examples.pulse.trainer.HeartRates; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +/// What a member does between visits, and what they tell the studio: their +/// visit statistics, their workouts, the heart rate their watch shares in +/// class, the times they are away, and the messages they were sent. +@Component +public class Engagement { + /// An away period is at most half a year; longer is a cancellation. + static final int AWAY_MAX_DAYS = 183; + /// The weeks [StatsDto#weeklyVisits] covers, oldest first. + static final int WEEKS = 12; + private static final long DAY = 86400000L; + + private final CheckInRepository checkIns; + private final WorkoutRepository workouts; + private final AwayPeriodRepository away; + private final NudgeRepository nudges; + private final CampaignRepository campaigns; + private final BookingRepository bookings; + private final MemberRepository members; + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final Billing billing; + private final Studio studio; + private final HeartRates rates; + private final LiveHub live; + + public Engagement(CheckInRepository checkIns, WorkoutRepository workouts, + AwayPeriodRepository away, NudgeRepository nudges, CampaignRepository campaigns, + BookingRepository bookings, MemberRepository members, + MembershipRepository memberships, MembershipPlanRepository plans, Billing billing, + Studio studio, HeartRates rates, LiveHub live) { + this.checkIns = checkIns; + this.workouts = workouts; + this.away = away; + this.nudges = nudges; + this.campaigns = campaigns; + this.bookings = bookings; + this.members = members; + this.memberships = memberships; + this.plans = plans; + this.billing = billing; + this.studio = studio; + this.rates = rates; + this.live = live; + } + + // ------------------------------------------------------------- stats + + /// The account's visits and effort: this month, all time, the streak of + /// weeks with a visit, and a visit count for each of the last + /// [#WEEKS] weeks. + @Transactional(readOnly = true) + public StatsDto stats(String account) { + long now = System.currentTimeMillis(); + LocalDate today = LocalDate.ofEpochDay(now / DAY); + Instant month = Instant.ofEpochMilli(today.withDayOfMonth(1).toEpochDay() * DAY); + long thisWeek = week(today.toEpochDay()); + List year = checkIns.findByAccountAndAtGreaterThanEqualOrderByAtAsc(account, + Instant.ofEpochMilli(now - 371L * DAY)); + boolean[] seen = new boolean[54]; + int[] weekly = new int[WEEKS]; + for (int iter = 0; iter < year.size(); iter++) { + long ago = thisWeek - week(year.get(iter).getAt().toEpochMilli() / DAY); + if (ago >= 0 && ago < seen.length) { + seen[(int) ago] = true; + } + if (ago >= 0 && ago < WEEKS) { + weekly[WEEKS - 1 - (int) ago]++; + } + } + List weeks = new ArrayList(); + for (int iter = 0; iter < WEEKS; iter++) { + weeks.add(Integer.valueOf(weekly[iter])); + } + int minutes = 0; + int calories = 0; + List done = workouts.findByAccountAndStartedAtGreaterThanEqual(account, month); + for (int iter = 0; iter < done.size(); iter++) { + minutes += done.get(iter).getMinutes(); + calories += done.get(iter).getCalories(); + } + Instant last = checkIns.lastVisit(account); + int visits = (int) checkIns.countByAccount(account); + int streak = streak(seen); + List all = workouts.findByAccountOrderByStartedAtDesc(account); + int points = Points.score(visits, streak, all); + int level = Points.level(points); + return new StatsDto((int) checkIns.countByAccountAndAtGreaterThanEqual(account, month), + visits, streak, minutes, calories, last == null ? 0 : last.toEpochMilli(), weeks, + points, level, Points.floor(level), Points.next(level), + Points.badges(visits, streak, all)); + } + + /// The minutes a stored zone list (`"m1,m2,m3,m4,m5"`) spent in `zone`, + /// counted from 1; 0 for a zone it does not have. + static int zoneMinutes(String zones, int zone) { + if (zones == null || zone < 1) { + return 0; + } + int at = 0; + for (int iter = 1; iter < zone; iter++) { + at = zones.indexOf(',', at) + 1; + if (at <= 0) { + return 0; + } + } + int end = zones.indexOf(',', at); + try { + return Integer.parseInt(zones.substring(at, end < 0 ? zones.length() : end).trim()); + } catch (NumberFormatException bad) { + return 0; + } + } + + /// Weeks in a row with a visit, counting back from this week -- or from + /// last week while this one has none yet, so a streak is not lost on a + /// Monday morning. + static int streak(boolean[] seen) { + int at = seen[0] ? 0 : 1; + int count = 0; + while (at < seen.length && seen[at]) { + count++; + at++; + } + return count; + } + + /// The week a day falls in, counted in Monday-to-Sunday weeks. Day 0, + /// 1 January 1970, was a Thursday. + public static long week(long epochDay) { + return Math.floorDiv(epochDay + 3, 7); + } + + // ------------------------------------------------------------- away + + @Transactional(readOnly = true) + public List away(String account) { + List rows = away.findByAccountAndEndsOnGreaterThanEqualOrderByStartsOnAsc(account, + LocalDate.now(ZoneOffset.UTC)); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Records a time the member will not come: no marketing reaches them + /// meanwhile, and with `freezeBilling` their memberships are frozen for + /// it too ([Billing#freeze], which has its own limits). + @Transactional(rollbackFor = IOException.class) + public AwayDto addAway(String account, AwayDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Choose the dates"); + } + LocalDate from = Billing.date(request.startsOn()); + LocalDate until = Billing.date(request.endsOn()); + LocalDate today = LocalDate.now(ZoneOffset.UTC); + if (from.isBefore(today) || until.isBefore(from) + || until.toEpochDay() - from.toEpochDay() > AWAY_MAX_DAYS) { + throw new ResponseStatusException(400, "Away starts today or later and lasts at most " + + AWAY_MAX_DAYS + " days"); + } + if (away.findByAccountAndEndsOnGreaterThanEqualOrderByStartsOnAsc(account, today).size() + >= 10) { + throw new ResponseStatusException(409, "That is enough time away planned"); + } + AwayPeriod row = new AwayPeriod(); + row.setId(Ids.next()); + row.setAccount(account); + row.setStartsOn(from); + row.setEndsOn(until); + row.setReason(cut(request.reason() == null ? "" : request.reason().trim(), 120)); + row.setFreezeBilling(request.freezeBilling()); + row.setCreatedAt(Instant.now()); + away.insert(row); + if (request.freezeBilling()) { + List held = memberships.findByAccountOrderByStartedAtDesc(account); + int frozen = 0; + for (int iter = 0; iter < held.size(); iter++) { + Membership one = held.get(iter); + MembershipPlan plan = plans.findById(one.getPlanId()); + if (Billing.ACTIVE.equals(one.getState()) && plan != null + && !Household.counted(plan)) { + // The day after: an away period names the last day away, + // a freeze the day billing resumes. + billing.freeze(one, from, until.plusDays(1), today); + frozen++; + } + } + if (frozen == 0) { + throw new ResponseStatusException(409, "There is no membership to freeze"); + } + } + return toDto(row); + } + + /// Withdraws an away period. A billing freeze made for it that has not + /// ended is ended too. + @Transactional(rollbackFor = IOException.class) + public AwayDto removeAway(String account, String id) { + AwayPeriod row = id == null || id.length() > 32 ? null + : away.findFirstByIdAndAccount(id, account); + if (row == null) { + throw new ResponseStatusException(404, "No such time away"); + } + if (row.isFreezeBilling()) { + Instant start = Instant.ofEpochMilli(row.getStartsOn().toEpochDay() * DAY); + List held = memberships.findByAccountOrderByStartedAtDesc(account); + for (int iter = 0; iter < held.size(); iter++) { + if (held.get(iter).getFrozenFrom().equals(start)) { + billing.unfreeze(account, held.get(iter).getId()); + } + } + } + away.deleteById(row.getId()); + return toDto(row); + } + + /// Whether `account` said they would be away on `day`. + public boolean isAway(String account, LocalDate day) { + return away.existsByAccountAndStartsOnLessThanEqualAndEndsOnGreaterThanEqual(account, day, + day); + } + + private static AwayDto toDto(AwayPeriod row) { + return new AwayDto(row.getId(), row.getStartsOn().toString(), row.getEndsOn().toString(), + row.getReason(), row.isFreezeBilling()); + } + + // ------------------------------------------------------------- inbox + + @Transactional(readOnly = true) + public List nudges(String account) { + List rows = nudges.findByAccountOrderBySentAtDesc(account); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size() && iter < 50; iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Marks a message read; a campaign counts it opened once. + @Transactional(rollbackFor = IOException.class) + public NudgeDto opened(String account, String id) { + Nudge row = id == null || id.length() > 32 ? null : nudges.findFirstByIdAndAccount(id, + account); + if (row == null) { + throw new ResponseStatusException(404, "No such message"); + } + if (row.getOpenedAt().equals(Instant.EPOCH)) { + row.setOpenedAt(Instant.now()); + if (row.getCampaignId().length() > 0) { + campaigns.countOpen(row.getCampaignId()); + } + } + return toDto(row); + } + + public static NudgeDto toDto(Nudge row) { + return new NudgeDto(row.getId(), row.getKind(), row.getSessionId(), row.getTitle(), + row.getBody(), row.getSentAt().toEpochMilli(), row.getOpenedAt().toEpochMilli()); + } + + // ------------------------------------------------------------- workouts + + /// Keeps a workout the member's phone or watch recorded. The same start + /// sent twice -- a sync that retried -- answers the one already kept. + @Transactional(rollbackFor = IOException.class) + public WorkoutDto addWorkout(String account, WorkoutDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Send a workout"); + } + long now = System.currentTimeMillis(); + if (request.startedAt() <= 0 || request.startedAt() > now + 60000L + || request.minutes() < 1 || request.minutes() > 600 + || request.avgHr() < 0 || request.avgHr() > 250 + || request.maxHr() < 0 || request.maxHr() > 250 + || request.calories() < 0 || request.calories() > 10000 + || request.zones() != null && request.zones().size() > 5) { + throw new ResponseStatusException(400, "That workout does not add up"); + } + Instant started = Instant.ofEpochMilli(request.startedAt()); + List mine = workouts.findByAccountAndStartedAtGreaterThanEqual(account, started); + for (int iter = 0; iter < mine.size(); iter++) { + if (mine.get(iter).getStartedAt().equals(started)) { + return toDto(mine.get(iter)); + } + } + String sessionId = request.sessionId() == null ? "" : request.sessionId(); + ClassSession attended = studio.sessionOrNull(sessionId); + if (attended == null) { + sessionId = ""; + } + Workout row = new Workout(); + row.setId(Ids.next()); + row.setAccount(account); + row.setSessionId(sessionId); + // A class's workout is called what the class is called, whatever the + // device named it, so the history and the friends' feed say "HIIT Burn". + String named = attended == null ? null : studio.className(attended); + row.setActivity(cut(named != null ? named : text(request.activity(), "Workout"), 60)); + row.setStartedAt(started); + row.setMinutes(request.minutes()); + row.setAvgHr(request.avgHr()); + row.setMaxHr(request.maxHr()); + row.setCalories(request.calories()); + row.setZones(zones(request.zones())); + row.setSource(cut(text(request.source(), "app"), 30)); + row.setShared(request.shared()); + row.setCreatedAt(Instant.ofEpochMilli(now)); + workouts.insert(row); + return toDto(row); + } + + @Transactional(readOnly = true) + public List workouts(String account) { + List rows = workouts.findByAccountOrderByStartedAtDesc(account); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size() && iter < 100; iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + public static WorkoutDto toDto(Workout row) { + List zones = new ArrayList(); + String text = row.getZones(); + int at = 0; + while (at < text.length()) { + int end = text.indexOf(',', at); + if (end < 0) { + end = text.length(); + } + try { + zones.add(Integer.valueOf(Integer.parseInt(text.substring(at, end)))); + } catch (NumberFormatException bad) { + zones.add(Integer.valueOf(0)); + } + at = end + 1; + } + return new WorkoutDto(row.getId(), row.getSessionId(), row.getActivity(), + row.getStartedAt().toEpochMilli(), row.getMinutes(), row.getAvgHr(), + row.getMaxHr(), row.getCalories(), zones, row.getSource(), row.isShared(), + Points.of(row)); + } + + private static String zones(List minutes) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; minutes != null && iter < minutes.size(); iter++) { + if (iter > 0) { + out.append(','); + } + Integer value = minutes.get(iter); + out.append(value == null ? 0 : Math.max(0, Math.min(600, value.intValue()))); + } + return out.toString(); + } + + // ------------------------------------------------------------- heart rate + + /// One reading from a member's watch during a class they are in, handed + /// to the trainer's roster. Not stored: see [HeartRates]. + @Transactional(readOnly = true) + public HeartRateDto heartRate(String account, HeartRateDto reading) { + if (reading == null || reading.bpm() < 30 || reading.bpm() > 230) { + throw new ResponseStatusException(400, "That is not a heart rate"); + } + ClassSession session = studio.sessionOrNull(reading.sessionId()); + long now = System.currentTimeMillis(); + if (session == null || now < session.getStartsAt().toEpochMilli() - 600000L + || now > session.getEndsAt().toEpochMilli() + 600000L) { + throw new ResponseStatusException(409, "That class is not under way"); + } + Member who = null; + List rows = bookings.findBySessionIdOrderByCreatedAtAsc(session.getId()); + for (int iter = 0; iter < rows.size() && who == null; iter++) { + Booking row = rows.get(iter); + if (row.getAccount().equals(account) && (Bookings.CHECKED_IN.equals(row.getState()) + || Bookings.BOOKED.equals(row.getState()))) { + Member member = members.findById(row.getMemberId()); + // A parent's watch reads the parent: their own place, when + // they have one, is the one the reading belongs to. + if (member != null && Accounts.SELF.equals(member.getRelation())) { + who = member; + } + } + } + if (who == null) { + throw new ResponseStatusException(409, "You are not in that class"); + } + int zone = HeartRates.zone(reading.bpm(), Accounts.age(who.getBirthDate(), + LocalDate.ofEpochDay(now / DAY))); + rates.put(session.getId(), who.getId(), reading.bpm(), zone, now); + live.heartRate(session.getTrainer(), session.getId(), who.getId(), reading.bpm(), zone); + return reading; + } + + private static String text(String value, String otherwise) { + return value == null || value.trim().length() == 0 ? otherwise : value.trim(); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/EngagementEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/EngagementEndpoint.java new file mode 100644 index 00000000000..e7cb2f8219e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/EngagementEndpoint.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.AwayDto; +import com.codenameone.examples.pulse.api.EngagementApiServer; +import com.codenameone.examples.pulse.api.HeartRateDto; +import com.codenameone.examples.pulse.api.NudgeDto; +import com.codenameone.examples.pulse.api.StatsDto; +import com.codenameone.examples.pulse.api.WorkoutDto; + +import java.util.List; + +/// The server's half of `EngagementApi`. +@Component +public class EngagementEndpoint implements EngagementApiServer { + private final Engagement engagement; + + public EngagementEndpoint(Engagement engagement) { + this.engagement = engagement; + } + + @Override + public StatsDto stats() throws Exception { + return engagement.stats(Caller.name()); + } + + @Override + public List away() throws Exception { + return engagement.away(Caller.name()); + } + + @Override + public AwayDto addAway(AwayDto body) throws Exception { + return engagement.addAway(Caller.name(), body); + } + + @Override + public AwayDto removeAway(String id) throws Exception { + return engagement.removeAway(Caller.name(), id); + } + + @Override + public List nudges() throws Exception { + return engagement.nudges(Caller.name()); + } + + @Override + public NudgeDto opened(String id) throws Exception { + return engagement.opened(Caller.name(), id); + } + + @Override + public WorkoutDto addWorkout(WorkoutDto body) throws Exception { + return engagement.addWorkout(Caller.name(), body); + } + + @Override + public List workouts() throws Exception { + return engagement.workouts(Caller.name()); + } + + @Override + public HeartRateDto heartRate(HeartRateDto body) throws Exception { + return engagement.heartRate(Caller.name(), body); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Nudger.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Nudger.java new file mode 100644 index 00000000000..22c64865a49 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Nudger.java @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.Nudge; +import com.codenameone.examples.pulse.push.Notifier; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CampaignRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.studio.Studio; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// The marketing that runs by itself. +/// +/// - **Fill a class**: a class starting in [#FILL_FROM_HOURS] to +/// [#FILL_UNTIL_HOURS] hours that is under [#FILL_SHARE]% full is offered +/// to members who have taken that kind of class before, a few per empty +/// place. +/// - **We miss you**: a member with a membership who has not been in for +/// [#MISS_DAYS] days is told so, once every [#MISS_AGAIN_DAYS] days. +/// +/// Neither ever reaches a member who said they are away +/// ([Engagement#isAway]), one who turned promotions off, or one inside their +/// quiet hours; the last are left for a later run rather than skipped. Each +/// message is its own transaction, re-checked inside it, so two servers +/// running the job together send it once ([Scheduled#lock] makes that the +/// exception anyway). +@Component +public class Nudger { + static final int FILL_FROM_HOURS = 3; + static final int FILL_UNTIL_HOURS = 36; + static final int FILL_SHARE = 60; + /// Invitations sent per empty place, at most. + static final int FILL_PER_PLACE = 3; + /// A member is asked to fill a class at most once in this many hours. + static final int FILL_AGAIN_HOURS = 48; + static final int MISS_DAYS = 14; + static final int MISS_AGAIN_DAYS = 21; + /// Members gone longer than this are not chased: they have left. + static final int MISS_GIVE_UP_DAYS = 120; + private static final long HOUR = 3600000L; + private static final long DAY = 24L * HOUR; + + private final ClassSessionRepository sessions; + private final ClassTypeRepository types; + private final BookingRepository bookings; + private final CheckInRepository checkIns; + private final MembershipRepository memberships; + private final NudgeRepository nudges; + private final CampaignRepository campaigns; + private final Engagement engagement; + private final Notifier notifier; + + public Nudger(ClassSessionRepository sessions, ClassTypeRepository types, + BookingRepository bookings, CheckInRepository checkIns, + MembershipRepository memberships, NudgeRepository nudges, + CampaignRepository campaigns, Engagement engagement, Notifier notifier) { + this.sessions = sessions; + this.types = types; + this.bookings = bookings; + this.checkIns = checkIns; + this.memberships = memberships; + this.nudges = nudges; + this.campaigns = campaigns; + this.engagement = engagement; + this.notifier = notifier; + } + + @Scheduled(fixedDelay = 900000, initialDelay = 120000, lock = "pulse-nudges") + public void run() { + Instant now = Instant.now(); + try { + fill(now); + missYou(now); + } catch (IOException | RuntimeException err) { + System.err.println("[nudges] " + err); + } + } + + /// Offers the places left in classes that need filling. + /// + /// @return how many members were told + public int fill(Instant now) throws IOException { + List soon = upcoming(now); + int told = 0; + for (int iter = 0; iter < soon.size(); iter++) { + ClassSession session = soon.get(iter); + int left = session.getCapacity() - session.getBooked(); + if (!Studio.SCHEDULED.equals(session.getState()) || left <= 0 + || session.getBooked() * 100 >= session.getCapacity() * FILL_SHARE) { + continue; + } + List who = candidates(session); + int sent = 0; + for (int at = 0; at < who.size() && sent < left * FILL_PER_PLACE; at++) { + if (offer(who.get(at), session.getId(), now)) { + sent++; + } + } + told += sent; + } + return told; + } + + @Transactional(readOnly = true) + List upcoming(Instant now) { + return sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.plusMillis(FILL_FROM_HOURS * HOUR), now.plusMillis(FILL_UNTIL_HOURS * HOUR)); + } + + /// Who has taken this kind of class and is not in this one. + @Transactional(readOnly = true) + List candidates(ClassSession session) { + Set in = new HashSet(); + List rows = bookings.findBySessionIdOrderByCreatedAtAsc(session.getId()); + for (int iter = 0; iter < rows.size(); iter++) { + in.add(rows.get(iter).getAccount()); + } + List out = new ArrayList(); + List took = bookings.accountsWhoTook(session.getClassTypeId()); + for (int iter = 0; iter < took.size(); iter++) { + if (!in.contains(took.get(iter))) { + out.add(took.get(iter)); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + boolean offer(String account, String sessionId, Instant now) throws IOException { + ClassSession session = sessions.findById(sessionId); + LocalDate day = LocalDate.ofEpochDay(session.getStartsAt().toEpochMilli() / DAY); + if (engagement.isAway(account, day) + || nudges.existsByAccountAndSessionIdAndKind(account, sessionId, Notifier.FILL) + || nudges.existsByAccountAndKindAndSentAtGreaterThan(account, Notifier.FILL, + now.minusMillis(FILL_AGAIN_HOURS * HOUR)) + || !notifier.reachable(account, now)) { + return false; + } + ClassType type = types.findById(session.getClassTypeId()); + String name = type == null ? "A class" : type.getName(); + int left = session.getCapacity() - session.getBooked(); + notifier.tell(account, Notifier.FILL, "", sessionId, name + " has room", + left + (left == 1 ? " place is" : " places are") + " left in " + name + + ". Want one?", now); + return true; + } + + /// Tells members who have not been in for a while that they are missed. + /// + /// @return how many were told + public int missYou(Instant now) throws IOException { + List gone = missing(now); + int told = 0; + for (int iter = 0; iter < gone.size(); iter++) { + if (miss(gone.get(iter), now)) { + told++; + } + } + return told; + } + + @Transactional(readOnly = true) + List missing(Instant now) { + long since = now.toEpochMilli() - MISS_DAYS * DAY; + long giveUp = now.toEpochMilli() - MISS_GIVE_UP_DAYS * DAY; + List rows = checkIns.lastVisits(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + long last = millis(row[1]); + if (row[0] instanceof String && last < since && last > giveUp) { + out.add((String) row[0]); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + boolean miss(String account, Instant now) throws IOException { + if (engagement.isAway(account, LocalDate.ofEpochDay(now.toEpochMilli() / DAY)) + || nudges.existsByAccountAndKindAndSentAtGreaterThan(account, Notifier.MISS_YOU, + now.minusMillis(MISS_AGAIN_DAYS * DAY)) + || !member(account) || !notifier.reachable(account, now)) { + return false; + } + notifier.tell(account, Notifier.MISS_YOU, "", "", "We miss you", + "It has been a while. Your next class is a tap away.", now); + return true; + } + + private boolean member(String account) { + List held = memberships.findByAccountOrderByStartedAtDesc(account); + for (int iter = 0; iter < held.size(); iter++) { + if ("ACTIVE".equals(held.get(iter).getState())) { + return true; + } + } + return false; + } + + /// Credits the message that brought a member to book a class, once. Call + /// inside the transaction that booked it. + public void booked(String account, String sessionId, Instant now) { + Nudge row = nudges.findFirstByAccountAndSessionIdAndBookedAtOrderBySentAtDesc(account, + sessionId, Instant.EPOCH); + if (row != null) { + row.setBookedAt(now); + if (row.getCampaignId().length() > 0) { + campaigns.countBooking(row.getCampaignId()); + } + } + } + + /// An aggregate's maximum comes back as whatever the driver makes of a + /// timestamp column. + static long millis(Object value) { + if (value instanceof Instant) { + return ((Instant) value).toEpochMilli(); + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return 0; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Points.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Points.java new file mode 100644 index 00000000000..c5864876bd0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/engagement/Points.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codenameone.examples.pulse.domain.Workout; + +import java.util.ArrayList; +import java.util.List; + +/// The game on top of training: points for turning up and for what a member +/// records about it, levels for the points, and badges for milestones. +/// +/// Everything is worked out from the visits and workouts already stored, so +/// nothing here can drift from them and there is nothing to migrate when a +/// rule changes. The rules favour the habit over the effort: a visit is worth +/// more than a long workout, and a workout with a heart rate is worth more +/// than one without, most of all one measured by a chest strap or a watch, +/// which is what the class view and the trainer's screen are built on. +public final class Points { + /// A visit to the studio. + static final int VISIT = 10; + /// Each week of the current streak. + static final int STREAK_WEEK = 5; + /// Each five minutes of a recorded workout. + static final int FIVE_MINUTES = 1; + /// A workout recorded with a heart rate. + static final int HEART_RATE = 15; + /// A workout measured by a strap or a watch rather than estimated. + static final int WORN_SENSOR = 10; + + /// The points each level starts at. + static final int[] LEVELS = {0, 100, 250, 500, 1000, 2000, 4000}; + + /// The workout sources that were measured by something worn. + public static final String STRAP = "STRAP"; + public static final String WATCH = "WATCH"; + + /// Every badge, in the order a member earns them, more or less. + public static final String[] BADGES = {"FIRST_CLASS", "TEN_CLASSES", "FIFTY_CLASSES", + "STREAK_4", "STREAK_12", "HEART_TRACKED", "STRAPPED_IN", "ZONE_5", "TEN_HOURS"}; + + private Points() { + } + + /// The points for a record of visits, a streak and workouts. + static int score(int visits, int streak, List workouts) { + int points = visits * VISIT + streak * STREAK_WEEK; + for (int iter = 0; iter < workouts.size(); iter++) { + points += of(workouts.get(iter)); + } + return points; + } + + /// The points one workout earned. + public static int of(Workout row) { + int points = row.getMinutes() / 5 * FIVE_MINUTES; + if (row.getAvgHr() > 0) { + points += HEART_RATE; + if (worn(row.getSource())) { + points += WORN_SENSOR; + } + } + return points; + } + + /// The level, from 1, for a number of points. + static int level(int points) { + int level = 1; + while (level < LEVELS.length && points >= LEVELS[level]) { + level++; + } + return level; + } + + /// The points a level starts at. + static int floor(int level) { + return LEVELS[Math.max(0, Math.min(LEVELS.length, level) - 1)]; + } + + /// The points the level after this one starts at, or this one's at the top. + static int next(int level) { + return level < LEVELS.length ? LEVELS[level] : floor(level); + } + + /// The badges earned, in [#BADGES] order. + static List badges(int visits, int streak, List workouts) { + boolean heart = false; + boolean worn = false; + boolean peak = false; + int minutes = 0; + for (int iter = 0; iter < workouts.size(); iter++) { + Workout row = workouts.get(iter); + minutes += row.getMinutes(); + heart |= row.getAvgHr() > 0; + worn |= row.getAvgHr() > 0 && worn(row.getSource()); + peak |= Engagement.zoneMinutes(row.getZones(), 5) > 0; + } + boolean[] earned = {visits >= 1, visits >= 10, visits >= 50, streak >= 4, streak >= 12, + heart, worn, peak, minutes >= 600}; + List out = new ArrayList(); + for (int iter = 0; iter < BADGES.length; iter++) { + if (earned[iter]) { + out.add(BADGES[iter]); + } + } + return out; + } + + static boolean worn(String source) { + return STRAP.equals(source) || WATCH.equals(source); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveEndpoint.java new file mode 100644 index 00000000000..e681325f687 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveEndpoint.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.live; + +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.UserDto; + +/// The live channel: one WebSocket per signed-in app, down which the server +/// says what changed ([LiveHub]). +/// +/// No filter chain covers this path, deliberately: the handshake cannot carry +/// the bearer token on every platform. The connection is let in by the ticket +/// in its address instead ([LiveTickets]), and one that presents none, or one +/// already used, is closed before it is told anything. +@WebSocketMapping("/ws/live") +public class LiveEndpoint implements WebSocket { + private final LiveTickets tickets; + private final Accounts accounts; + private final LiveHub hub; + + public LiveEndpoint(LiveTickets tickets, Accounts accounts, LiveHub hub) { + this.tickets = tickets; + this.accounts = accounts; + this.hub = hub; + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + String username = tickets.redeem(ticket(session.getQuery())); + UserDto user = username == null ? null : accounts.describe(username); + if (user == null || user.suspended()) { + session.close(4401, "ticket"); + return; + } + // Who this connection is, kept on the connection: the one piece of + // state a WebSocket has that a request does not. + session.setAttachment(username); + hub.add(username, staff(user), session); + // Read again now that the connection is in the hub. A block or a + // withdrawn staff role commits and then drops the account's + // connections; one that committed after the read above and dropped + // before the add found nothing to drop, and this connection would + // carry what was read before -- for staff, every check-in at the door. + // Either the change is seen here, or its drop comes after the add and + // closes this connection. + UserDto now = accounts.describe(username); + if (now == null || now.suspended() || staff(now) != staff(user)) { + hub.remove(username, session); + session.close(4403, "changed"); + return; + } + session.sendText("{\"type\":\"ready\"}"); + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + // The app says one thing, to learn the connection is still alive on a + // network that drops idle ones without telling either end. + if (session.getAttachment() != null && message != null + && message.indexOf("\"ping\"") >= 0) { + session.sendText("{\"type\":\"pong\"}"); + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) + throws Exception { + session.close(1003, "text only"); + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + Object username = session.getAttachment(); + if (username instanceof String) { + hub.remove((String) username, session); + } + } + + /// The `ticket` parameter of a query string, or null. A ticket is hex, so + /// there is nothing in it to decode. + static String ticket(String query) { + if (query == null) { + return null; + } + int at = 0; + while (at < query.length()) { + int end = query.indexOf('&', at); + if (end < 0) { + end = query.length(); + } + if (query.startsWith("ticket=", at)) { + return query.substring(at + 7, end); + } + at = end + 1; + } + return null; + } + + /// Whether an account is sent what only staff see. + private static boolean staff(UserDto user) { + return user.desk() || user.owner() || user.trainer(); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveHub.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveHub.java new file mode 100644 index 00000000000..70916e52f9d --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveHub.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.live; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The open live connections, and what is pushed down them. +/// +/// Four frames, each a small JSON object: +/// +/// - `{"type":"session","id":...,"booked":...,"waitlisted":...,"state":...}` -- a +/// class filled up, emptied or was cancelled. Sent to everyone connected, so an +/// open timetable shows the places left without being asked. +/// - `{"type":"booking","id":...,"state":...}` -- one of an account's places +/// changed: a waitlist turned into a place, a class was cancelled. Sent to that +/// account, which then reads the booking from the API. The frame carries no +/// more than that on purpose: the API is the one account of a booking, and a +/// client that missed a frame is put right by its next read. +/// - `{"type":"heartrate","sessionId":...,"memberId":...,"bpm":...,"zone":...}` -- +/// a member sharing their heart rate during a class. Sent to the class's +/// trainer only. +/// - `{"type":"checkin","locationId":...,"name":...,"admitted":...}` -- someone +/// came through a door. Sent to staff. +/// +/// The connections are those of this process. Two instances of the server +/// would each know only their own, and a change made on one would not reach a +/// client connected to the other: running more than one needs a channel +/// between them -- a database LISTEN/NOTIFY, a message broker -- feeding the +/// same methods. +@Component +public class LiveHub { + private final Map> byUser = + new HashMap>(); + private final List staff = new ArrayList(); + + synchronized void add(String username, boolean isStaff, WebSocketSession session) { + List open = byUser.get(username); + if (open == null) { + open = new ArrayList(); + byUser.put(username, open); + } + open.add(session); + if (isStaff) { + staff.add(session); + } + } + + synchronized void remove(String username, WebSocketSession session) { + List open = byUser.get(username); + if (open != null) { + open.remove(session); + if (open.isEmpty()) { + byUser.remove(username); + } + } + staff.remove(session); + } + + /// How many connections are open, for the tests. + public synchronized int connections() { + int total = 0; + for (List open : byUser.values()) { + total += open.size(); + } + return total; + } + + /// Closes every connection an account has open. + public void drop(String username) { + List open; + synchronized (this) { + open = byUser.remove(username); + if (open != null) { + staff.removeAll(open); + } + } + for (int iter = 0; open != null && iter < open.size(); iter++) { + open.get(iter).close(4403, "suspended"); + } + } + + /// Tells everyone connected how full a class is now. + public void sessionChanged(String id, int booked, int waitlisted, String state) { + Map frame = new LinkedHashMap(); + frame.put("type", "session"); + frame.put("id", id); + frame.put("booked", Integer.valueOf(booked)); + frame.put("waitlisted", Integer.valueOf(waitlisted)); + frame.put("state", state); + List to = new ArrayList(); + synchronized (this) { + for (List open : byUser.values()) { + to.addAll(open); + } + } + send(to, Json.write(frame)); + } + + /// Tells an account that one of its bookings changed. + public void bookingChanged(String account, String id, String state) { + Map frame = new LinkedHashMap(); + frame.put("type", "booking"); + frame.put("id", id); + frame.put("state", state); + List to = new ArrayList(); + synchronized (this) { + collect(to, account); + } + send(to, Json.write(frame)); + } + + /// Hands a member's heart rate to the trainer of the class they are in. + public void heartRate(String trainer, String sessionId, String memberId, int bpm, int zone) { + Map frame = new LinkedHashMap(); + frame.put("type", "heartrate"); + frame.put("sessionId", sessionId); + frame.put("memberId", memberId); + frame.put("bpm", Integer.valueOf(bpm)); + frame.put("zone", Integer.valueOf(zone)); + List to = new ArrayList(); + synchronized (this) { + collect(to, trainer); + } + send(to, Json.write(frame)); + } + + /// Tells the staff connected that someone came through a door. + public void checkedIn(String locationId, String name, boolean admitted) { + List to; + synchronized (this) { + if (staff.isEmpty()) { + return; + } + to = new ArrayList(staff); + } + Map frame = new LinkedHashMap(); + frame.put("type", "checkin"); + frame.put("locationId", locationId); + frame.put("name", name); + frame.put("admitted", Boolean.valueOf(admitted)); + send(to, Json.write(frame)); + } + + /// Must be called holding the lock. + private void collect(List to, String username) { + List open = username == null ? null : byUser.get(username); + for (int iter = 0; open != null && iter < open.size(); iter++) { + if (!to.contains(open.get(iter))) { + to.add(open.get(iter)); + } + } + } + + /// Sends outside the lock: a slow client holds up its own frame, not + /// everyone's. One that cannot be written to is dropped; its close callback + /// takes it out of the maps. + private static void send(List to, String text) { + for (int iter = 0; iter < to.size(); iter++) { + WebSocketSession session = to.get(iter); + try { + session.sendText(text); + } catch (IOException gone) { + session.abort(); + } + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveTickets.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveTickets.java new file mode 100644 index 00000000000..c70fac0f6d5 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/live/LiveTickets.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.live; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.TicketDto; +import com.codenameone.examples.pulse.domain.LiveTicket; +import com.codenameone.examples.pulse.repository.LiveTicketRepository; + +import java.io.IOException; +import java.time.Instant; + +/// Tickets for the live channel. +/// +/// A WebSocket handshake is one request the app cannot attach its bearer token +/// to on every platform -- a browser's WebSocket takes no headers at all -- and a +/// token in the address would end up in every access log on the way. So the app +/// asks for a ticket over the authenticated API and presents that instead: it +/// is random, it names nothing, it is good for one connection, and it is stale +/// in half a minute. +@Component +public class LiveTickets { + static final int TICKET_SECONDS = 30; + + private final LiveTicketRepository tickets; + + public LiveTickets(LiveTicketRepository tickets) { + this.tickets = tickets; + } + + /// Not held against the account being closed, as the rows that last are. + /// A ticket issued in the instant an account is erased outlives it by at + /// most [#TICKET_SECONDS] plus one [#sweep], and opens nothing meanwhile: + /// `LiveEndpoint` checks the account again when the ticket is redeemed. + /// A row lock on every socket connect is not worth that minute. + @Transactional + public TicketDto issue(String username) throws IOException { + String ticket = Ids.hex(Crypto.randomBytes(24)); + LiveTicket row = new LiveTicket(); + row.setTicketHash(Ids.sha256(ticket)); + row.setUsername(username); + row.setExpiresAt(Instant.ofEpochMilli(System.currentTimeMillis() + TICKET_SECONDS * 1000L)); + tickets.insert(row); + return new TicketDto(ticket, TICKET_SECONDS); + } + + /// The account a ticket was issued to, or null when it is unknown, used or + /// stale. Redeeming it uses it up: the row is deleted, and only the + /// connection whose delete removed it is let in, so a ticket presented twice + /// at the same moment still opens one connection. + @Transactional + public String redeem(String ticket) throws IOException { + if (ticket == null || ticket.length() == 0 || ticket.length() > 128) { + return null; + } + String hash = Ids.sha256(ticket); + LiveTicket row = tickets.findById(hash); + if (row == null) { + return null; + } + String username = row.getUsername(); + long expiresAt = row.getExpiresAt().toEpochMilli(); + // One delete statement: the count it answers is what says this + // connection, and not another presenting the same ticket, used it. + int removed = tickets.deleteByTicketHash(hash); + if (removed != 1 || expiresAt < System.currentTimeMillis()) { + return null; + } + return username; + } + + /// Clears the tickets nobody came back for. + @Scheduled(fixedDelay = 60000, initialDelay = 60000) + @Transactional + public void sweep() throws IOException { + tickets.deleteByExpiresAtLessThan(Instant.now()); + } + + /// Forgets the tickets of an account that is being closed. + @Transactional + public void forget(String username) { + tickets.deleteByUsername(username); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Insights.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Insights.java new file mode 100644 index 00000000000..6b859fda3db --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Insights.java @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.api.CoachStatDto; +import com.codenameone.examples.pulse.api.InsightsDto; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.MemberDetailDto; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.OwnerMemberDto; +import com.codenameone.examples.pulse.api.PlanMixDto; +import com.codenameone.examples.pulse.api.TypeFillDto; +import com.codenameone.examples.pulse.api.WeekPointDto; +import com.codenameone.examples.pulse.billing.Billing; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.CheckIn; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Invoice; +import com.codenameone.examples.pulse.domain.Location; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.engagement.Engagement; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.LocationRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Household; +import com.codenameone.examples.pulse.studio.Studio; +import com.codenameone.examples.pulse.trainer.Trainers; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The owner's longer view: twelve weeks of growth, churn and attendance, +/// how each kind of class fills, and the member list with each member's +/// story. Weeks start on Monday and hours are read on the clock of the +/// studio's own time zone, never the server's. +@Component +public class Insights { + public static final String AT_RISK = "AT_RISK"; + public static final String NEW = "NEW"; + static final int WEEKS = 12; + private static final int BUSY_WEEKS = 8; + private static final int RECENT_DAYS = 28; + + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final InvoiceRepository invoices; + private final ClassSessionRepository sessions; + private final ClassTypeRepository types; + private final BookingRepository bookings; + private final CheckInRepository checkIns; + private final MemberRepository members; + private final LocationRepository locations; + private final StaffRepository staff; + private final Engagement engagement; + private final Studio studio; + private final Trainers trainers; + private final Billing billing; + + public Insights(MembershipRepository memberships, MembershipPlanRepository plans, + InvoiceRepository invoices, ClassSessionRepository sessions, ClassTypeRepository types, + BookingRepository bookings, CheckInRepository checkIns, MemberRepository members, + LocationRepository locations, StaffRepository staff, Engagement engagement, + Studio studio, Trainers trainers, Billing billing) { + this.memberships = memberships; + this.plans = plans; + this.invoices = invoices; + this.sessions = sessions; + this.types = types; + this.bookings = bookings; + this.checkIns = checkIns; + this.members = members; + this.locations = locations; + this.staff = staff; + this.engagement = engagement; + this.studio = studio; + this.trainers = trainers; + this.billing = billing; + } + + // -------------------------------------------------------------- insights + + @Transactional(readOnly = true) + public InsightsDto insights() { + long now = System.currentTimeMillis(); + ZoneId zone = studio.zone(); + LocalDate firstWeek = Studio.monday(LocalDate.now(zone)).minusWeeks(WEEKS - 1); + long from = firstWeek.atStartOfDay(zone).toInstant().toEpochMilli(); + List visits = checkIns.findByAtGreaterThanEqual(Instant.ofEpochMilli(from)); + List all = memberships.findAll(); + + // Busy hours: the opening hours of the earliest and the latest site. + int firstHour = 24; + int lastHour = 0; + List sites = locations.findAll(); + for (int iter = 0; iter < sites.size(); iter++) { + firstHour = Math.min(firstHour, sites.get(iter).getOpensMinute() / 60); + lastHour = Math.max(lastHour, (sites.get(iter).getClosesMinute() + 59) / 60); + } + if (firstHour >= lastHour) { + firstHour = 6; + lastHour = 22; + } + int span = lastHour - firstHour; + int[] busy = new int[7 * span]; + long busyFrom = now - BUSY_WEEKS * 7L * Days.MILLIS; + int[] weekVisits = new int[WEEKS]; + int recentVisits = 0; + for (int iter = 0; iter < visits.size(); iter++) { + Instant at = visits.get(iter).getAt(); + ZonedDateTime local = at.atZone(zone); + int week = (int) ((local.toLocalDate().toEpochDay() - firstWeek.toEpochDay()) / 7); + if (week >= 0 && week < WEEKS) { + weekVisits[week]++; + } + if (at.toEpochMilli() >= now - RECENT_DAYS * Days.MILLIS) { + recentVisits++; + } + int hour = local.getHour(); + if (at.toEpochMilli() >= busyFrom && hour >= firstHour && hour < lastHour) { + busy[(local.getDayOfWeek().getValue() - 1) * span + hour - firstHour]++; + } + } + List busyHours = new ArrayList(); + for (int iter = 0; iter < busy.length; iter++) { + busyHours.add(Integer.valueOf(busy[iter])); + } + + // Growth: what was running at the end of each week, the current one + // up to now. + List weeks = new ArrayList(); + for (int week = 0; week < WEEKS; week++) { + LocalDate monday = firstWeek.plusWeeks(week); + long start = monday.atStartOfDay(zone).toInstant().toEpochMilli(); + long end = Math.min(now, monday.plusWeeks(1).atStartOfDay(zone).toInstant() + .toEpochMilli()); + int active = 0; + int joined = 0; + int left = 0; + for (int iter = 0; iter < all.size(); iter++) { + Membership row = all.get(iter); + if (!counted(row)) { + continue; + } + long started = row.getStartedAt().toEpochMilli(); + long ended = ended(row); + if (started < end && ended >= end) { + active++; + } + if (started >= start && started < end) { + joined++; + } + if (ended >= start && ended < end) { + left++; + } + } + weeks.add(new WeekPointDto(monday.toString(), active, joined, left, + weekVisits[week])); + } + + // Plans, revenue and the members behind them, as the dashboard counts + // monthly recurring revenue. + Map byPlan = new HashMap(); + int running = 0; + long mrr = 0; + int cohort = 0; + int stayed = 0; + for (int iter = 0; iter < all.size(); iter++) { + Membership row = all.get(iter); + long started = row.getStartedAt().toEpochMilli(); + if (counted(row) && started <= now - 90L * Days.MILLIS + && started > now - 180L * Days.MILLIS) { + cohort++; + if (Billing.ACTIVE.equals(row.getState()) || Billing.FROZEN.equals(row.getState()) + || Billing.PAST_DUE.equals(row.getState())) { + stayed++; + } + } + if (!running(row)) { + continue; + } + long[] mix = byPlan.get(row.getPlanId()); + if (mix == null) { + mix = new long[2]; + byPlan.put(row.getPlanId(), mix); + } + mix[0]++; + MembershipPlan plan = plans.findById(row.getPlanId()); + if (plan != null && !Household.counted(plan) && plan.getPeriodMonths() > 0 + && !Billing.FROZEN.equals(row.getState())) { + long monthly = plan.getPriceCents() / plan.getPeriodMonths(); + mix[1] += monthly; + mrr += monthly; + } + if (!Billing.FROZEN.equals(row.getState())) { + running++; + } + } + List mixes = new ArrayList(); + for (Map.Entry entry : byPlan.entrySet()) { + MembershipPlan plan = plans.findById(entry.getKey()); + mixes.add(new PlanMixDto(plan == null ? entry.getKey() : plan.getName(), + plan == null ? "" : plan.getKind(), (int) entry.getValue()[0], + entry.getValue()[1])); + } + mixes.sort((a, b) -> b.members() - a.members()); + + // Classes: the last four weeks of each kind. + List month = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + Instant.ofEpochMilli(now - RECENT_DAYS * Days.MILLIS), Instant.ofEpochMilli(now)); + Map byType = new HashMap(); + Map typeOf = new HashMap(); + List ids = new ArrayList(); + for (int iter = 0; iter < month.size(); iter++) { + ClassSession row = month.get(iter); + if (Studio.CANCELLED.equals(row.getState()) || row.getEndsAt().toEpochMilli() > now) { + continue; + } + long[] fill = tally(byType, row.getClassTypeId()); + fill[0]++; + fill[1] += row.getBooked(); + fill[2] += row.getCapacity(); + typeOf.put(row.getId(), row.getClassTypeId()); + ids.add(row.getId()); + } + long came = 0; + long missed = 0; + List booked = ids.isEmpty() ? new ArrayList() : bookings.findBySessionIdIn(ids); + for (int iter = 0; iter < booked.size(); iter++) { + Booking row = booked.get(iter); + long[] fill = byType.get(typeOf.get(row.getSessionId())); + if (Bookings.CHECKED_IN.equals(row.getState())) { + came++; + fill[3]++; + } else if (Bookings.NO_SHOW.equals(row.getState())) { + missed++; + fill[4]++; + } + } + List classes = new ArrayList(); + for (Map.Entry entry : byType.entrySet()) { + ClassType type = types.findById(entry.getKey()); + long[] fill = entry.getValue(); + classes.add(new TypeFillDto(type == null ? entry.getKey() : type.getName(), + type == null ? "" : type.getColor(), (int) fill[0], + percent(fill[1], fill[2]), percent(fill[4], fill[3] + fill[4]))); + } + classes.sort((a, b) -> b.fillPercent() - a.fillPercent()); + + // Coaches: every active trainer who taught in the four weeks. + List coaches = new ArrayList(); + List team = staff.findByActiveTrueOrderByUsernameAsc(); + for (int iter = 0; iter < team.size(); iter++) { + CoachStatDto coach = trainers.coach(team.get(iter).getUsername()); + if (coach.classes() > 0) { + coaches.add(coach); + } + } + coaches.sort((a, b) -> b.attendees() - a.attendees()); + + return new InsightsDto(firstHour, lastHour, busyHours, weeks, classes, mixes, coaches, + percent(stayed, cohort), + running == 0 ? 0 : (int) (recentVisits * 10L * 7 / RECENT_DAYS / running), + percent(missed, came + missed), running == 0 ? 0 : mrr / running); + } + + // --------------------------------------------------------------- members + + /// Running memberships, or those `filter` names, by member name. + @Transactional(readOnly = true) + public List members(String filter) { + String wanted = filter == null ? "" : filter.trim(); + long now = System.currentTimeMillis(); + List rows = memberships.findAll(); + List chosen = new ArrayList(); + List ids = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Membership row = rows.get(iter); + boolean shown = Billing.CANCELLED.equalsIgnoreCase(wanted) + ? Billing.CANCELLED.equals(row.getState()) : running(row); + if (shown) { + chosen.add(row); + ids.add(row.getMemberId()); + } + } + Map names = new HashMap(); + if (!ids.isEmpty()) { + List found = members.findByIdIn(ids); + for (int iter = 0; iter < found.size(); iter++) { + names.put(found.get(iter).getId(), found.get(iter)); + } + } + Map last = lastVisits(); + Map recent = new HashMap(); + List month = checkIns.findByAtGreaterThanEqual( + Instant.ofEpochMilli(now - 30L * Days.MILLIS)); + for (int iter = 0; iter < month.size(); iter++) { + tallyInt(recent, month.get(iter).getMemberId()); + } + LocalDate today = LocalDate.now(studio.zone()); + Map away = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < chosen.size(); iter++) { + Membership row = chosen.get(iter); + OwnerMemberDto dto = dto(row, names.get(row.getMemberId()), last, recent, away, + today, now); + if (AT_RISK.equalsIgnoreCase(wanted) && !dto.atRisk() + || NEW.equalsIgnoreCase(wanted) + && dto.joinedAt() < now - 30L * Days.MILLIS + || (Billing.PAST_DUE.equalsIgnoreCase(wanted) + || Billing.FROZEN.equalsIgnoreCase(wanted)) + && !wanted.equalsIgnoreCase(dto.state())) { + continue; + } + out.add(dto); + } + out.sort((a, b) -> a.name().compareToIgnoreCase(b.name())); + return out; + } + + /// One membership's member: their visits a week, the classes they come + /// to and what they have paid. + @Transactional(readOnly = true) + public MemberDetailDto member(String id) { + Membership row = id == null ? null : memberships.findById(id); + if (row == null) { + throw new ResponseStatusException(404, "No such membership"); + } + long now = System.currentTimeMillis(); + Member member = members.findById(row.getMemberId()); + Map last = lastVisits(); + Map recent = new HashMap(); + ZoneId zone = studio.zone(); + LocalDate thisWeek = Studio.monday(LocalDate.now(zone)); + // A year of visits: twelve weeks are drawn, and a streak may be longer. + LocalDate yearAgo = thisWeek.minusWeeks(52); + List visits = checkIns.findByMemberIdAndAtGreaterThanEqualOrderByAtAsc( + row.getMemberId(), yearAgo.atStartOfDay(zone).toInstant()); + int[] byWeek = new int[53]; + for (int iter = 0; iter < visits.size(); iter++) { + CheckIn visit = visits.get(iter); + if (visit.getAt().toEpochMilli() >= now - 30L * Days.MILLIS) { + tallyInt(recent, visit.getMemberId()); + } + LocalDate day = visit.getAt().atZone(zone).toLocalDate(); + int week = (int) ((day.toEpochDay() - yearAgo.toEpochDay()) / 7); + if (week >= 0 && week < byWeek.length) { + byWeek[week]++; + } + } + List visitsByWeek = new ArrayList(); + for (int week = byWeek.length - WEEKS; week < byWeek.length; week++) { + visitsByWeek.add(Integer.valueOf(byWeek[week])); + } + // A week not yet over does not break a streak; it only adds to one. + int streak = 0; + int week = byWeek.length - 1; + if (byWeek[week] == 0) { + week--; + } + while (week >= 0 && byWeek[week] > 0) { + streak++; + week--; + } + if (byWeek[byWeek.length - 1] > 0 && streak == 0) { + streak = 1; + } + + Map byType = new HashMap(); + List came = bookings.findByMemberIdAndStateOrderByCheckedInAtDesc( + row.getMemberId(), Bookings.CHECKED_IN); + List sessionIds = new ArrayList(); + for (int iter = 0; iter < came.size(); iter++) { + sessionIds.add(came.get(iter).getSessionId()); + } + if (!sessionIds.isEmpty()) { + List taken = sessions.findByIdIn(sessionIds); + for (int iter = 0; iter < taken.size(); iter++) { + tallyInt(byType, taken.get(iter).getClassTypeId()); + } + } + List favourites = new ArrayList(); + for (Map.Entry entry : byType.entrySet()) { + ClassType type = types.findById(entry.getKey()); + favourites.add(new NameCountDto(type == null ? entry.getKey() : type.getName(), + entry.getValue()[0])); + } + favourites.sort((a, b) -> b.count() - a.count()); + + List bills = invoices.findByMemberIdOrderByCreatedAtDesc(row.getMemberId()); + List shown = new ArrayList(); + long lifetime = 0; + for (int iter = 0; iter < bills.size(); iter++) { + Invoice bill = bills.get(iter); + if (Billing.PAID.equals(bill.getStatus())) { + lifetime += bill.getAmountCents() - bill.getCreditCents(); + } + shown.add(billing.toDto(bill)); + } + OwnerMemberDto summary = dto(row, member, last, recent, new HashMap(), + LocalDate.now(zone), now); + return new MemberDetailDto(summary, visitsByWeek, favourites, streak, lifetime, shown); + } + + private OwnerMemberDto dto(Membership row, Member member, Map last, + Map recent, Map away, LocalDate today, long now) { + MembershipPlan plan = plans.findById(row.getPlanId()); + Long seen = last.get(row.getMemberId()); + long lastVisit = seen == null ? 0 : seen.longValue(); + Boolean isAway = away.get(row.getAccount()); + if (isAway == null) { + isAway = Boolean.valueOf(engagement.isAway(row.getAccount(), today)); + away.put(row.getAccount(), isAway); + } + // Someone who joined this week has not been gone two weeks: a member + // never seen counts from the day they joined. + long since = Math.max(lastVisit, row.getStartedAt().toEpochMilli()); + // A frozen membership is a pause the member asked for, not a drift. + boolean atRisk = (Billing.ACTIVE.equals(row.getState()) + || Billing.PAST_DUE.equals(row.getState())) && since < now - 14L * Days.MILLIS + && !isAway.booleanValue(); + int[] visits = recent.get(row.getMemberId()); + return new OwnerMemberDto(row.getId(), row.getAccount(), + member == null ? row.getAccount() : member.getName(), + plan == null ? "" : plan.getName(), row.getState(), + row.getStartedAt().toEpochMilli(), lastVisit, visits == null ? 0 : visits[0], + atRisk, isAway.booleanValue()); + } + + /// When each member last came in. + private Map lastVisits() { + List rows = checkIns.lastVisitsByMember(); + Map out = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + Object value = rows.get(iter)[1]; + long millis = value instanceof Instant ? ((Instant) value).toEpochMilli() + : value instanceof Number ? ((Number) value).longValue() : 0; + out.put(String.valueOf(rows.get(iter)[0]), Long.valueOf(millis)); + } + return out; + } + + private static boolean running(Membership row) { + return Billing.ACTIVE.equals(row.getState()) || Billing.FROZEN.equals(row.getState()) + || Billing.PAST_DUE.equals(row.getState()); + } + + /// A membership that ever ran: not one still waiting for its first payment. + private static boolean counted(Membership row) { + return !Billing.PENDING.equals(row.getState()); + } + + /// When a membership stopped running: the last change of a cancelled or + /// expired one, never for the rest. + private static long ended(Membership row) { + return Billing.CANCELLED.equals(row.getState()) || Billing.EXPIRED.equals(row.getState()) + ? row.getUpdatedAt().toEpochMilli() : Long.MAX_VALUE; + } + + private static int percent(long part, long whole) { + return whole <= 0 ? 0 : (int) Math.min(100L, part * 100 / whole); + } + + private static long[] tally(Map counts, String key) { + long[] count = counts.get(key); + if (count == null) { + count = new long[5]; + counts.put(key, count); + } + return count; + } + + private static void tallyInt(Map counts, String key) { + int[] count = counts.get(key); + if (count == null) { + count = new int[1]; + counts.put(key, count); + } + count[0]++; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Owner.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Owner.java new file mode 100644 index 00000000000..8359854dc7d --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/Owner.java @@ -0,0 +1,734 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.Roles; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.CampaignDto; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.api.DayAmountDto; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.NewSessionDto; +import com.codenameone.examples.pulse.api.OwnerDashboardDto; +import com.codenameone.examples.pulse.api.PlanDto; +import com.codenameone.examples.pulse.api.RoomDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.api.StaffDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.billing.Billing; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.Campaign; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Invoice; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Room; +import com.codenameone.examples.pulse.domain.Shift; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.engagement.Engagement; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.push.Notifier; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CampaignRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.LocationRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; +import com.codenameone.examples.pulse.repository.ShiftRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Household; +import com.codenameone.examples.pulse.studio.Studio; +import com.codenameone.examples.pulse.trainer.Trainers; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Running the studio: the numbers, the plans and classes on offer, the +/// timetable, the staff and their shifts, and the campaigns that fill it. +@Component +public class Owner { + /// Plan kinds; [Household#counted] are the two that are used up. + static final List PLAN_KINDS = Arrays.asList("MONTHLY", "ANNUAL", "CLASS_PACK", + "DROP_IN"); + /// Campaign audiences: those who took the class's kind, every member + /// with an active membership, or those who have not been in for a month. + public static final String CLASS = "CLASS"; + public static final String ALL = "ALL"; + public static final String LAPSED = "LAPSED"; + static final int MOST_WEEKS = 12; + static final int MOST_RECIPIENTS = 5000; + private static final long HOUR = 3600000L; + private static final long DAY = 24L * HOUR; + + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final InvoiceRepository invoices; + private final ClassSessionRepository sessions; + private final ClassTypeRepository types; + private final RoomRepository rooms; + private final LocationRepository locations; + private final StaffRepository staff; + private final ShiftRepository shifts; + private final BookingRepository bookings; + private final CheckInRepository checkIns; + private final CampaignRepository campaigns; + private final ProfileRepository profiles; + private final Accounts accounts; + private final Household household; + private final Studio studio; + private final Trainers trainers; + private final Engagement engagement; + private final Notifier notifier; + private final LiveHub live; + + public Owner(MembershipRepository memberships, MembershipPlanRepository plans, + InvoiceRepository invoices, ClassSessionRepository sessions, ClassTypeRepository types, + RoomRepository rooms, LocationRepository locations, StaffRepository staff, + ShiftRepository shifts, BookingRepository bookings, CheckInRepository checkIns, + CampaignRepository campaigns, ProfileRepository profiles, Accounts accounts, + Household household, Studio studio, Trainers trainers, Engagement engagement, + Notifier notifier, LiveHub live) { + this.memberships = memberships; + this.plans = plans; + this.invoices = invoices; + this.sessions = sessions; + this.types = types; + this.rooms = rooms; + this.locations = locations; + this.staff = staff; + this.shifts = shifts; + this.bookings = bookings; + this.checkIns = checkIns; + this.campaigns = campaigns; + this.profiles = profiles; + this.accounts = accounts; + this.household = household; + this.studio = studio; + this.trainers = trainers; + this.engagement = engagement; + this.notifier = notifier; + this.live = live; + } + + // ------------------------------------------------------------- dashboard + + /// The studio on one screen. Monthly recurring revenue counts what each + /// running membership brings in a month -- an annual plan a twelfth of + /// its price -- and leaves packs out, which are paid once. + @Transactional(readOnly = true) + public OwnerDashboardDto dashboard() throws IOException { + long now = System.currentTimeMillis(); + Instant month = Instant.ofEpochMilli(now - 30L * DAY); + List running = memberships.findByStateIn(Arrays.asList(Billing.ACTIVE, + Billing.FROZEN, Billing.PAST_DUE)); + Map planById = new HashMap(); + long mrr = 0; + int pastDue = 0; + Set members = new HashSet(); + for (int iter = 0; iter < running.size(); iter++) { + Membership row = running.get(iter); + members.add(row.getAccount()); + if (Billing.PAST_DUE.equals(row.getState())) { + pastDue++; + } + MembershipPlan plan = planById.get(row.getPlanId()); + if (plan == null) { + plan = plans.findById(row.getPlanId()); + if (plan == null) { + continue; + } + planById.put(plan.getId(), plan); + } + if (!Household.counted(plan) && plan.getPeriodMonths() > 0 + && !Billing.FROZEN.equals(row.getState())) { + mrr += plan.getPriceCents() / plan.getPeriodMonths(); + } + } + List paid = invoices.findByStatusAndPaidAtGreaterThanEqual(Billing.PAID, month); + Map byDay = new HashMap(); + long revenue = 0; + for (int iter = 0; iter < paid.size(); iter++) { + Invoice bill = paid.get(iter); + long amount = bill.getAmountCents() - bill.getCreditCents(); + revenue += amount; + String day = LocalDate.ofEpochDay(bill.getPaidAt().toEpochMilli() / DAY).toString(); + long[] total = byDay.get(day); + if (total == null) { + total = new long[2]; + byDay.put(day, total); + } + total[0] += amount; + total[1]++; + } + List days = new ArrayList(); + for (int iter = 29; iter >= 0; iter--) { + String day = LocalDate.ofEpochDay((now - iter * DAY) / DAY).toString(); + long[] total = byDay.get(day); + days.add(new DayAmountDto(day, total == null ? 0 : total[0], + total == null ? 0 : (int) total[1])); + } + return new OwnerDashboardDto(members.size(), + (int) memberships.countByStartedAtGreaterThanEqual(month), + (int) memberships.countByStateAndUpdatedAtGreaterThanEqual(Billing.CANCELLED, month), + mrr, revenue, utilisation(now), (int) checkIns.countByAtGreaterThanEqual( + Instant.ofEpochMilli(now - 7L * DAY)), pastDue, atRisk(members, now), + days, popular(now), underfilled(now)); + } + + /// Places taken of places offered in the last week's classes, in percent. + private int utilisation(long now) { + List week = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + Instant.ofEpochMilli(now - 7L * DAY), Instant.ofEpochMilli(now)); + long taken = 0; + long offered = 0; + for (int iter = 0; iter < week.size(); iter++) { + ClassSession row = week.get(iter); + if (!Studio.CANCELLED.equals(row.getState())) { + taken += row.getBooked(); + offered += row.getCapacity(); + } + } + return offered == 0 ? 0 : (int) (taken * 100 / offered); + } + + /// Members who have not been in for two weeks and did not say they + /// would be away. + private int atRisk(Set members, long now) { + List last = checkIns.lastVisits(); + Map seen = new HashMap(); + for (int iter = 0; iter < last.size(); iter++) { + seen.put(String.valueOf(last.get(iter)[0]), Long.valueOf(millis(last.get(iter)[1]))); + } + LocalDate today = LocalDate.ofEpochDay(now / DAY); + int count = 0; + for (String account : members) { + Long when = seen.get(account); + if ((when == null || when.longValue() < now - 14L * DAY) + && !engagement.isAway(account, today)) { + count++; + } + } + return count; + } + + private static long millis(Object value) { + if (value instanceof Instant) { + return ((Instant) value).toEpochMilli(); + } + return value instanceof Number ? ((Number) value).longValue() : 0; + } + + /// The five kinds of class most booked in the last month. + private List popular(long now) { + List month = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + Instant.ofEpochMilli(now - 30L * DAY), Instant.ofEpochMilli(now)); + Map byType = new HashMap(); + for (int iter = 0; iter < month.size(); iter++) { + ClassSession row = month.get(iter); + int[] count = byType.get(row.getClassTypeId()); + if (count == null) { + count = new int[1]; + byType.put(row.getClassTypeId(), count); + } + count[0] += row.getBooked(); + } + List out = new ArrayList(); + for (Map.Entry entry : byType.entrySet()) { + ClassType type = types.findById(entry.getKey()); + out.add(new NameCountDto(type == null ? entry.getKey() : type.getName(), + entry.getValue()[0])); + } + out.sort((a, b) -> b.count() - a.count()); + return out.size() > 5 ? new ArrayList(out.subList(0, 5)) : out; + } + + /// Classes in the next two days under half full. + private List underfilled(long now) throws IOException { + List soon = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + Instant.ofEpochMilli(now), Instant.ofEpochMilli(now + 2L * DAY)); + List out = new ArrayList(); + for (int iter = 0; iter < soon.size(); iter++) { + ClassSession row = soon.get(iter); + if (Studio.SCHEDULED.equals(row.getState()) && row.getBooked() * 2 < row.getCapacity()) { + out.add(row); + } + } + return studio.describe("", out); + } + + // ------------------------------------------------------------- catalogue + + /// Adds a plan, or changes one. A change reaches members at their next + /// renewal, never the period they paid for; a retired plan + /// (`active` false) is offered to nobody new and renews for those on it. + @Transactional(rollbackFor = IOException.class) + public PlanDto savePlan(PlanDto wanted) throws IOException { + if (wanted == null || blank(wanted.name()) || !PLAN_KINDS.contains(wanted.kind()) + || wanted.priceCents() < 0 || wanted.priceCents() > 10000000L + || wanted.minAge() < 0 || wanted.maxAge() < 0 + || wanted.maxAge() > 0 && wanted.maxAge() < wanted.minAge() + || blank(wanted.currency()) || wanted.currency().trim().length() != 3) { + throw new ResponseStatusException(400, "A plan needs a name, a kind, a price and a" + + " currency, and an age band that makes sense"); + } + boolean counted = "CLASS_PACK".equals(wanted.kind()) || "DROP_IN".equals(wanted.kind()); + if (counted ? wanted.classCredits() < 1 || wanted.classCredits() > 200 + : wanted.periodMonths() < 1 || wanted.periodMonths() > 24) { + throw new ResponseStatusException(400, counted ? "A pack holds 1 to 200 classes" + : "A plan renews every 1 to 24 months"); + } + MembershipPlan row = blank(wanted.id()) ? null : plans.findById(wanted.id()); + boolean fresh = row == null; + if (fresh) { + row = new MembershipPlan(); + row.setId(Ids.next()); + } else if (!row.getKind().equals(wanted.kind())) { + throw new ResponseStatusException(409, "A plan's kind cannot change; add a new plan"); + } + row.setName(cut(wanted.name().trim(), 60)); + row.setDescription(cut(wanted.description() == null ? "" : wanted.description().trim(), + 400)); + row.setKind(wanted.kind()); + row.setPriceCents(wanted.priceCents()); + row.setCurrency(wanted.currency().trim()); + row.setPeriodMonths(counted ? 0 : wanted.periodMonths()); + row.setClassCredits(counted ? wanted.classCredits() : 0); + row.setMinAge(wanted.minAge()); + row.setMaxAge(wanted.maxAge()); + row.setOffPeakOnly(wanted.offPeakOnly()); + row.setActive(wanted.active()); + if (fresh) { + plans.insert(row); + } + return Billing.plan(row); + } + + @Transactional(rollbackFor = IOException.class) + public ClassTypeDto saveClassType(ClassTypeDto wanted) throws IOException { + if (wanted == null || blank(wanted.name()) || wanted.minutes() < 10 + || wanted.minutes() > 240 || wanted.capacity() < 1 || wanted.capacity() > 200 + || wanted.intensity() < 1 || wanted.intensity() > 5 || wanted.minAge() < 0 + || wanted.maxAge() < 0 || wanted.maxAge() > 0 && wanted.maxAge() < wanted.minAge()) { + throw new ResponseStatusException(400, "A class needs a name, 10 to 240 minutes, a" + + " capacity, an intensity of 1 to 5 and an age band that makes sense"); + } + ClassType row = blank(wanted.id()) ? null : types.findById(wanted.id()); + boolean fresh = row == null; + if (fresh) { + row = new ClassType(); + row.setId(Ids.next()); + } + row.setName(cut(wanted.name().trim(), 60)); + row.setDescription(cut(text(wanted.description()), 400)); + row.setCategory(cut(text(wanted.category()), 30)); + row.setIntensity(wanted.intensity()); + row.setMinAge(wanted.minAge()); + row.setMaxAge(wanted.maxAge()); + row.setCapacity(wanted.capacity()); + row.setMinutes(wanted.minutes()); + row.setColor(cut(text(wanted.color()), 9)); + row.setActive(wanted.active()); + if (fresh) { + types.insert(row); + } + return Studio.type(row); + } + + @Transactional(readOnly = true) + public List rooms() { + List rows = rooms.findAllByOrderByLocationIdAscNameAsc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Room row = rows.get(iter); + out.add(new RoomDto(row.getId(), row.getLocationId(), row.getName(), row.getCapacity())); + } + return out; + } + + // ------------------------------------------------------------- timetable + + /// Puts a class on the timetable, the same time each week for `weeks` + /// weeks. Nothing is added when any week clashes with a class in the + /// same room or with the same trainer; the answer names the first. + @Transactional(rollbackFor = IOException.class) + public List schedule(NewSessionDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Describe the class"); + } + ClassType type = blank(wanted.classTypeId()) ? null : types.findById(wanted.classTypeId()); + Room room = blank(wanted.roomId()) ? null : rooms.findById(wanted.roomId()); + if (type == null || !type.isActive() || room == null) { + throw new ResponseStatusException(404, "No such class or room"); + } + teacher(wanted.trainer()); + int weeks = wanted.weeks() <= 0 ? 1 : wanted.weeks(); + int capacity = wanted.capacity() <= 0 ? type.getCapacity() : wanted.capacity(); + if (weeks > MOST_WEEKS || capacity > room.getCapacity() + || wanted.startsAt() <= System.currentTimeMillis()) { + throw new ResponseStatusException(400, "A class starts in the future, fits its room" + + " (" + room.getCapacity() + ") and repeats for at most " + MOST_WEEKS + + " weeks"); + } + List made = new ArrayList(); + for (int week = 0; week < weeks; week++) { + Instant start = Instant.ofEpochMilli(wanted.startsAt() + week * 7L * DAY); + Instant end = start.plusMillis(type.getMinutes() * 60000L); + Long clash = sessions.clashes(room.getId(), wanted.trainer(), start, end); + if (clash != null && clash.longValue() > 0) { + throw new ResponseStatusException(409, "The room or the trainer is taken on " + + LocalDate.ofEpochDay(start.toEpochMilli() / DAY)); + } + ClassSession row = new ClassSession(); + row.setId(Ids.next()); + row.setClassTypeId(type.getId()); + row.setLocationId(room.getLocationId()); + row.setRoomId(room.getId()); + row.setTrainer(wanted.trainer()); + row.setStartsAt(start); + row.setEndsAt(end); + row.setCapacity(capacity); + row.setState(Studio.SCHEDULED); + sessions.insert(row); + made.add(row); + } + return studio.describe("", made); + } + + /// Cancels a class: every place is given back -- with its credit, for a + /// pack -- and everyone booked or waiting is told. + public SessionDto cancelClass(String id, String reason) throws IOException { + List told = cancel(id, reason); + for (int iter = 0; iter < told.size(); iter++) { + live.bookingChanged(told.get(iter).getAccount(), told.get(iter).getId(), + Bookings.CANCELLED); + } + live.sessionChanged(id, 0, 0, Studio.CANCELLED); + return described(id); + } + + @Transactional(rollbackFor = IOException.class) + List cancel(String id, String reason) throws IOException { + ClassSession session = studio.session(id); + Instant now = Instant.now(); + if (!Studio.SCHEDULED.equals(session.getState()) || !session.getStartsAt().isAfter(now)) { + throw new ResponseStatusException(409, "Only a class still to come can be cancelled"); + } + String why = cut(text(reason), 200); + session.setState(Studio.CANCELLED); + session.setNote(why); + session.setBooked(0); + session.setWaitlisted(0); + ClassType type = types.findById(session.getClassTypeId()); + String name = type == null ? "A class" : type.getName(); + List rows = bookings.findBySessionIdOrderByCreatedAtAsc(id); + List told = new ArrayList(); + Set accountsTold = new LinkedHashSet(); + for (int iter = 0; iter < rows.size(); iter++) { + Booking row = rows.get(iter); + boolean booked = Bookings.BOOKED.equals(row.getState()); + if (!booked && !Bookings.WAITLISTED.equals(row.getState())) { + continue; + } + if (booked) { + Membership held = household.current(row.getMemberId()); + MembershipPlan plan = held == null ? null : plans.findById(held.getPlanId()); + if (plan != null && Household.counted(plan)) { + memberships.returnCredit(held.getId()); + } + } + row.setState(Bookings.CANCELLED); + row.setUpdatedAt(now); + told.add(row); + if (accountsTold.add(row.getAccount())) { + notifier.tell(row.getAccount(), Notifier.CANCELLED, "", id, name + " is cancelled", + why.length() > 0 ? why : "Sorry -- the class will not run. Your place" + + " has been given back.", now); + } + } + return told; + } + + /// Hands a class to another trainer, who must be free then. + @Transactional(rollbackFor = IOException.class) + public SessionDto substitute(String id, String trainer) throws IOException { + ClassSession session = studio.session(id); + if (!Studio.SCHEDULED.equals(session.getState()) + || !session.getStartsAt().isAfter(Instant.now())) { + throw new ResponseStatusException(409, "Only a class still to come can change hands"); + } + teacher(trainer); + if (trainer.equals(session.getTrainer())) { + return studio.get("", id); + } + // The room is the class's own, so it is left out of the clash check + // by naming none. + Long clash = sessions.clashes("", trainer, session.getStartsAt(), session.getEndsAt()); + if (clash != null && clash.longValue() > 0) { + throw new ResponseStatusException(409, "That trainer teaches at the same time"); + } + session.setTrainer(trainer); + return studio.get("", id); + } + + private Staff teacher(String trainer) { + Staff row = blank(trainer) ? null : staff.findById(trainer); + if (row == null || !row.isActive()) { + throw new ResponseStatusException(404, "No such trainer"); + } + return row; + } + + // ------------------------------------------------------------- staff + + @Transactional(readOnly = true) + public List staff() throws IOException { + List rows = staff.findAllByOrderByUsernameAsc(); + long now = System.currentTimeMillis(); + Instant from = Instant.ofEpochMilli(now - now % (7L * DAY)); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter), from)); + } + return out; + } + + private StaffDto toDto(Staff row, Instant weekFrom) throws IOException { + UserDto user = accounts.describe(row.getUsername()); + List roles = new ArrayList(); + if (user != null && user.trainer()) { + roles.add(Roles.TRAINER); + } + if (user != null && user.desk()) { + roles.add(Roles.DESK); + } + if (user != null && user.owner()) { + roles.add(Roles.OWNER); + } + int classes = sessions.findByTrainerAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + row.getUsername(), weekFrom, weekFrom.plusMillis(7L * DAY)).size(); + Profile who = profiles.findById(row.getUsername()); + return new StaffDto(row.getUsername(), who == null ? "" : who.getDisplayName(), + row.getTitle(), row.getBio(), row.getLocationId(), + row.getCertifiedUntil().toEpochMilli(), roles, classes); + } + + /// Changes what is known about a member of staff and what they may do. + /// The account must exist; the roles go through [Accounts#setRoles], + /// which keeps a trainer with classes to come from losing the role. + public StaffDto saveStaff(String by, StaffDto wanted) throws IOException { + if (wanted == null || blank(wanted.username())) { + throw new ResponseStatusException(400, "Choose the person"); + } + List roles = wanted.roles() == null ? new ArrayList() : wanted.roles(); + accounts.setRoles(by, wanted.username(), roles.contains(Roles.TRAINER), + roles.contains(Roles.DESK), roles.contains(Roles.OWNER)); + return details(wanted); + } + + @Transactional(readOnly = true) + SessionDto described(String id) throws IOException { + return studio.get("", id); + } + + @Transactional(rollbackFor = IOException.class) + StaffDto details(StaffDto wanted) throws IOException { + if (!blank(wanted.locationId()) && locations.findById(wanted.locationId()) == null) { + throw new ResponseStatusException(404, "No such location"); + } + Staff row = staff.findById(wanted.username()); + boolean fresh = row == null; + if (fresh) { + row = new Staff(); + row.setUsername(wanted.username()); + row.setActive(true); + } + row.setTitle(cut(text(wanted.title()), 60)); + row.setBio(cut(text(wanted.bio()), 600)); + row.setLocationId(text(wanted.locationId())); + row.setCertifiedUntil(Instant.ofEpochMilli(Math.max(0, wanted.certifiedUntil()))); + if (fresh) { + staff.insert(row); + } + long now = System.currentTimeMillis(); + return toDto(row, Instant.ofEpochMilli(now - now % (7L * DAY))); + } + + @Transactional(readOnly = true) + public List shifts(String location) { + // No location is every location: the owner's shift list is the + // whole studio's unless they narrow it. + if (!blank(location) && locations.findById(location) == null) { + throw new ResponseStatusException(404, "No such location"); + } + Instant after = Instant.now().minusMillis(DAY); + List rows = blank(location) ? shifts.findByEndsAtGreaterThanOrderByStartsAtAsc(after) + : shifts.findByLocationIdAndEndsAtGreaterThanOrderByStartsAtAsc(location, after); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(trainers.dto(rows.get(iter))); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public ShiftDto addShift(ShiftDto wanted) throws IOException { + if (wanted == null || blank(wanted.duty()) || wanted.endsAt() <= wanted.startsAt() + || wanted.endsAt() - wanted.startsAt() > 12L * HOUR + || wanted.startsAt() <= System.currentTimeMillis()) { + throw new ResponseStatusException(400, "A shift has a duty and is up to 12 hours," + + " in the future"); + } + if (blank(wanted.locationId()) || locations.findById(wanted.locationId()) == null) { + throw new ResponseStatusException(404, "No such location"); + } + Staff who = blank(wanted.staff()) ? null : staff.findById(wanted.staff()); + if (who == null || !who.isActive()) { + throw new ResponseStatusException(404, "No such member of staff"); + } + Shift row = new Shift(); + row.setId(Ids.next()); + row.setLocationId(wanted.locationId()); + row.setStaff(who.getUsername()); + row.setDuty(cut(wanted.duty().trim(), 60)); + row.setStartsAt(Instant.ofEpochMilli(wanted.startsAt())); + row.setEndsAt(Instant.ofEpochMilli(wanted.endsAt())); + row.setState(Trainers.ASSIGNED); + shifts.insert(row); + return trainers.dto(row); + } + + // ------------------------------------------------------------- campaigns + + @Transactional(readOnly = true) + public List campaigns() { + List rows = campaigns.findAllByOrderByCreatedAtDesc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Sends a campaign to its audience ([#CLASS], [#ALL], [#LAPSED]) at + /// once. Members away today are left out; the rest get it in their inbox, + /// pushed when their preferences allow ([Notifier]). + @Transactional(rollbackFor = IOException.class) + public CampaignDto send(String by, CampaignDto wanted) throws IOException { + if (wanted == null || blank(wanted.name()) || blank(wanted.title()) + || blank(wanted.body())) { + throw new ResponseStatusException(400, "A campaign needs a name, a title and a" + + " message"); + } + Instant now = Instant.now(); + String sessionId = ""; + Set audience = new LinkedHashSet(); + if (CLASS.equals(wanted.kind())) { + ClassSession session = studio.session(wanted.sessionId()); + if (!Studio.SCHEDULED.equals(session.getState()) || !session.getStartsAt().isAfter(now)) { + throw new ResponseStatusException(409, "That class has started or is off"); + } + sessionId = session.getId(); + audience.addAll(bookings.accountsWhoTook(session.getClassTypeId())); + List in = bookings.findBySessionIdOrderByCreatedAtAsc(sessionId); + for (int iter = 0; iter < in.size(); iter++) { + audience.remove(in.get(iter).getAccount()); + } + } else if (ALL.equals(wanted.kind()) || LAPSED.equals(wanted.kind())) { + List running = memberships.findByStateIn(Arrays.asList(Billing.ACTIVE)); + for (int iter = 0; iter < running.size(); iter++) { + audience.add(running.get(iter).getAccount()); + } + if (LAPSED.equals(wanted.kind())) { + List last = checkIns.lastVisits(); + long cut = now.toEpochMilli() - 30L * DAY; + for (int iter = 0; iter < last.size(); iter++) { + if (millis(last.get(iter)[1]) >= cut) { + audience.remove(String.valueOf(last.get(iter)[0])); + } + } + } + } else { + throw new ResponseStatusException(400, "Choose who it goes to"); + } + Campaign row = new Campaign(); + row.setId(Ids.next()); + row.setName(cut(wanted.name().trim(), 80)); + row.setKind(wanted.kind()); + row.setSessionId(sessionId); + row.setTitle(cut(wanted.title().trim(), 120)); + row.setBody(cut(wanted.body().trim(), 400)); + row.setCreatedBy(by); + row.setCreatedAt(now); + LocalDate today = LocalDate.ofEpochDay(now.toEpochMilli() / DAY); + int sent = 0; + for (String account : audience) { + if (sent >= MOST_RECIPIENTS) { + break; + } + if (!engagement.isAway(account, today)) { + notifier.tell(account, Notifier.CAMPAIGN, row.getId(), sessionId, row.getTitle(), + row.getBody(), now); + sent++; + } + } + row.setSent(sent); + campaigns.insert(row); + return toDto(row); + } + + private static CampaignDto toDto(Campaign row) { + return new CampaignDto(row.getId(), row.getName(), row.getKind(), row.getSessionId(), + row.getTitle(), row.getBody(), row.getCreatedAt().toEpochMilli(), row.getSent(), + row.getOpened(), row.getBooked()); + } + + private static boolean blank(String value) { + return value == null || value.trim().length() == 0; + } + + private static String text(String value) { + return value == null ? "" : value.trim(); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/OwnerEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/OwnerEndpoint.java new file mode 100644 index 00000000000..8694b37f43a --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/owner/OwnerEndpoint.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.CampaignDto; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.api.InsightsDto; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.MemberDetailDto; +import com.codenameone.examples.pulse.api.NewSessionDto; +import com.codenameone.examples.pulse.api.OwnerApiServer; +import com.codenameone.examples.pulse.api.OwnerDashboardDto; +import com.codenameone.examples.pulse.api.OwnerMemberDto; +import com.codenameone.examples.pulse.api.PlanDto; +import com.codenameone.examples.pulse.api.ReasonDto; +import com.codenameone.examples.pulse.api.RoomDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.api.StaffDto; +import com.codenameone.examples.pulse.billing.Billing; + +import java.util.List; + +/// The server's half of `OwnerApi`. +@Component +@PreAuthorize("hasRole('OWNER')") +public class OwnerEndpoint implements OwnerApiServer { + private final Owner owner; + private final Billing billing; + private final Insights insights; + + public OwnerEndpoint(Owner owner, Billing billing, Insights insights) { + this.owner = owner; + this.billing = billing; + this.insights = insights; + } + + @Override + public OwnerDashboardDto dashboard() throws Exception { + return owner.dashboard(); + } + + @Override + public InsightsDto insights() throws Exception { + return insights.insights(); + } + + @Override + public List members(String filter) throws Exception { + return insights.members(filter); + } + + @Override + public MemberDetailDto member(String id) throws Exception { + return insights.member(id); + } + + @Override + public List plans() throws Exception { + return billing.plans(true); + } + + @Override + public PlanDto savePlan(PlanDto body) throws Exception { + return owner.savePlan(body); + } + + @Override + public ClassTypeDto saveClassType(ClassTypeDto body) throws Exception { + return owner.saveClassType(body); + } + + @Override + public List rooms() throws Exception { + return owner.rooms(); + } + + @Override + public List schedule(NewSessionDto body) throws Exception { + return owner.schedule(body); + } + + @Override + public SessionDto cancelClass(String id, ReasonDto body) throws Exception { + return owner.cancelClass(id, body == null ? "" : body.reason()); + } + + @Override + public SessionDto substitute(String id, String trainer) throws Exception { + return owner.substitute(id, trainer); + } + + @Override + public List staff() throws Exception { + return owner.staff(); + } + + @Override + public StaffDto saveStaff(StaffDto body) throws Exception { + return owner.saveStaff(Caller.name(), body); + } + + @Override + public List shifts(String location) throws Exception { + return owner.shifts(location); + } + + @Override + public ShiftDto addShift(ShiftDto body) throws Exception { + return owner.addShift(body); + } + + @Override + public List campaigns() throws Exception { + return owner.campaigns(); + } + + @Override + public CampaignDto send(CampaignDto body) throws Exception { + return owner.send(Caller.name(), body); + } + + @Override + public List invoices(String status) throws Exception { + return billing.all(status); + } + + @Override + public InvoiceDto voidInvoice(String id, ReasonDto body) throws Exception { + return billing.voidInvoice(id, body == null ? "" : body.reason()); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/MisconfiguredPayments.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/MisconfiguredPayments.java new file mode 100644 index 00000000000..fc71c75f708 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/MisconfiguredPayments.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codenameone.examples.pulse.api.CardDto; + +import java.io.IOException; + +/// The payment provider a server gets when the one it was told to use cannot +/// be set up: every call fails, and says why. +/// +/// The problem is logged when the server starts and again on every call, +/// naming the settings to fix. Each call throws an `IOException`, as an outage +/// of the processor would, so a customer is told payments are unavailable and no +/// delivery is charged to a card nobody can reach. +public class MisconfiguredPayments implements PaymentProvider { + private final String provider; + private final String problem; + + /// @param provider the provider that was asked for, which is what [#name] + /// answers so that stored references keep naming it + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPayments(String provider, String problem) { + this.provider = provider; + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public String name() { + return provider; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + throw fail(); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + throw fail(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + throw fail(); + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + throw fail(); + } + + @Override + public void detachCard(String customer, String token) throws IOException { + throw fail(); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + throw fail(); + } + + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + throw fail(); + } + + @Override + public void capture(String reference, long amountCents) throws IOException { + throw fail(); + } + + @Override + public void release(String reference) throws IOException { + throw fail(); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + throw fail(); + } + + @Override + public String refundStatus(String refundReference) throws IOException { + throw fail(); + } + + private IOException fail() { + System.err.println("[config] a payment call was refused: " + problem); + return new IOException(problem); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PayReturnPage.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PayReturnPage.java new file mode 100644 index 00000000000..53b7342c90a --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PayReturnPage.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +/// The page a hosted card setup ends on. +/// +/// The payment processor's page sends the browser here when the user has added +/// a card or given up. There is nothing to do here but say so: the app is what +/// finishes the setup, by asking the server to read it back, once the user +/// returns to it. That is also why the page needs no sign-in and takes nothing +/// from its address but which of two sentences to show -- being sent here +/// proves nothing and changes nothing. +@RestController +public class PayReturnPage { + @GetMapping("/pay/return") + public HttpServer.Response page( + @RequestParam(value = "result", required = false) String result) throws Exception { + boolean done = "done".equals(result); + String html = "" + + "" + + "Pulse" + + "

" + (done ? "Card added" : "No card was added") + "

" + + (done ? "You can close this page and go back to Pulse to finish." + : "You can close this page and go back to Pulse to try again.") + + "

"; + return new HttpServer.Response(200, "text/html; charset=utf-8", html.getBytes("UTF-8")); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentDeclinedException.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentDeclinedException.java new file mode 100644 index 00000000000..64e781ecdb0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentDeclinedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import java.io.IOException; + +/// A card was refused, or a setup was not finished: the processor was asked +/// and said no. The message is written for the person paying and is safe to +/// show them -- unlike that of a plain `IOException` from a provider, which +/// describes a fault and goes to the log. +public class PaymentDeclinedException extends IOException { + public PaymentDeclinedException(String message) { + super(message); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentEndpoint.java new file mode 100644 index 00000000000..db41ab246f7 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentEndpoint.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.CardDto; +import com.codenameone.examples.pulse.api.PaymentApiServer; +import com.codenameone.examples.pulse.api.PaymentConfigDto; +import com.codenameone.examples.pulse.api.PaymentMethodDto; +import com.codenameone.examples.pulse.api.PaymentSetupDto; + +import java.util.List; + +/// The server's half of `PaymentApi`. Every method acts as the signed-in user +/// and on that user's own cards: whose they are is never a +/// parameter, so there is no id to change to reach someone else's. +@Component +public class PaymentEndpoint implements PaymentApiServer { + private final Payments payments; + private final Accounts accounts; + private final String currency; + + /// `pulse.currency` is the studio's currency, ISO 4217, which every plan + /// is priced in. + public PaymentEndpoint(Payments payments, Accounts accounts, + @Value("${pulse.currency:USD}") String currency) { + this.payments = payments; + this.accounts = accounts; + this.currency = currency; + } + + @Override + public PaymentConfigDto config() throws Exception { + Caller.name(); + return payments.config(currency); + } + + @Override + public List methods() throws Exception { + return payments.methods(Caller.name()); + } + + @Override + public PaymentSetupDto startSetup() throws Exception { + String who = Caller.name(); + return payments.startSetup(who, accounts.describe(who).displayName()); + } + + @Override + public PaymentMethodDto completeSetup(String id, CardDto card) throws Exception { + return payments.completeSetup(Caller.name(), id, card); + } + + @Override + public PaymentMethodDto makeDefault(String id) throws Exception { + return payments.makeDefault(Caller.name(), id); + } + + @Override + public PaymentMethodDto remove(String id) throws Exception { + return payments.remove(Caller.name(), id); + } + +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentProvider.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentProvider.java new file mode 100644 index 00000000000..fb9619f0fa8 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/PaymentProvider.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codenameone.examples.pulse.api.CardDto; + +import java.io.IOException; + +/// A payment processor. The one place the server touches one. +/// +/// The shape is the one real processors share. A customer is a *customer* of the +/// processor; a card is saved to that customer once, in a *setup*, and comes +/// back as a token; from then on the server charges the token with nobody +/// present, which is what lets a delivery be paid for as it ends. The server keeps +/// the token, a brand and four digits. It never keeps a card number, and with +/// a hosted setup it never sees one. +// tag::pulse-payment-provider[] +public interface PaymentProvider { + /// `simulated` or `stripe`: what the app is told, so it knows how to add a + /// card. + String name(); + + /// Opens the processor's record of a customer and returns its reference. + String createCustomer(String username, String displayName) throws IOException; + + /// Deletes the processor's record of a customer, and the cards saved to it + /// with it. A record that is already gone is deleted: this is asked again + /// when the first answer was lost. + void forgetCustomer(String customer) throws IOException; + + /// Begins saving a card to `customer`. `setupId` is the server's own id + /// for the attempt, for the processor to hand back. + Setup startCardSetup(String customer, String setupId) throws IOException; + + /// Finishes a setup and returns the card it saved. + /// + /// @param reference what [#startCardSetup] returned as the setup's reference + /// @param typed the card as typed into the app, for a processor whose setup + /// is not hosted; ignored by one whose setup is + /// - `PaymentDeclinedException`: when the card was refused or the setup was + /// not finished + Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException; + + /// Takes a saved card off `customer`, so that it can't be charged again. + /// A card that is already off, or gone, is detached: this is asked again + /// when the first answer was lost. + /// + /// @param token what [#finishCardSetup] returned as the card's token + void detachCard(String customer, String token) throws IOException; + + /// Charges a saved card with its owner absent. + /// + /// @param key names this charge and no other. A processor asked twice with + /// the same key charges once, so a request repeated after a timeout + /// cannot take the money twice. + /// @return the processor's reference for the charge + /// - `PaymentDeclinedException`: when the card was refused + /// - `IOException`: when the processor could not be asked + String charge(String key, String customer, String token, long amountCents, String currency, + String description) throws IOException; + + /// Returns `amountCents` of a charge to the card it came from. + /// + /// A processor may take the refund and only later say whether the bank + /// returned the money: such a refund comes back [Refund#pending], and is + /// asked about with [#refundStatus] until it has an answer. + /// + /// @return the refund as the processor took it + /// - `IOException`: when the processor could not be asked, or refused the + /// refund outright + Refund refund(String chargeReference, long amountCents) throws IOException; + + /// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED], + /// [#REFUND_PENDING] while the processor still does not know, or + /// [#REFUND_FAILED] when the money did not go back. + String refundStatus(String refundReference) throws IOException; + /// Holds `amountCents` on a saved card without taking it, for an order + /// that is paid for only once it arrives. Keyed as [#charge] is. + /// + /// @return the processor's reference for the hold, for [#capture] or [#release] + /// - `PaymentDeclinedException`: when the card was refused + String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException; + /// Takes `amountCents`, at most what was held, from a hold. A hold that was + /// already captured is captured: this is asked again when the first answer + /// was lost. + void capture(String reference, long amountCents) throws IOException; + /// Lets a hold go, so the money is the cardholder's again. A hold that is + /// already let go is released. + void release(String reference) throws IOException; + // end::pulse-payment-provider[] + + String REFUND_SUCCEEDED = "succeeded"; + String REFUND_PENDING = "pending"; + String REFUND_FAILED = "failed"; + + /// A refund the processor took. + final class Refund { + public String reference = ""; + /// Whether the money is not yet known to be back on the card. + public boolean pending; + } + + /// A setup under way. + final class Setup { + /// Whether the card is typed into the processor's own page, at `url`. + public boolean hosted; + public String url = ""; + /// What to hand to [PaymentProvider#finishCardSetup]. + public String reference = ""; + } + + /// A saved card: what may be shown of it, and the token it is charged by. + final class Card { + public String brand = ""; + public String last4 = ""; + public int expMonth; + public int expYear; + public String token = ""; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/Payments.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/Payments.java new file mode 100644 index 00000000000..785cfcc06eb --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/Payments.java @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.CardDto; +import com.codenameone.examples.pulse.api.PaymentConfigDto; +import com.codenameone.examples.pulse.api.PaymentMethodDto; +import com.codenameone.examples.pulse.api.PaymentSetupDto; +import com.codenameone.examples.pulse.domain.PaymentCustomer; +import com.codenameone.examples.pulse.domain.PaymentMethod; +import com.codenameone.examples.pulse.domain.PaymentSetup; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.PaymentCustomerRepository; +import com.codenameone.examples.pulse.repository.PaymentMethodRepository; +import com.codenameone.examples.pulse.repository.PaymentSetupRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// The ways a member can pay: their saved cards, and cash at the desk. +/// +/// This is the wallet and nothing more. What is owed, and when it is charged, +/// is `billing/Billing`'s business; it calls [#charge] with an invoice as the +/// key, so a charge asked for twice is made once. +/// +/// Everything here that takes a `username` acts on that user's own methods and +/// no one else's; the endpoints pass the signed-in user. +/// +/// The processor is somewhere else, and a call to it can take seconds, so none +/// is made with a transaction open. Whatever talks to it is three steps: a +/// transaction that reads, or claims, what the call needs and commits; the +/// call; and a transaction that records what came of it, or gives the claim +/// back. +@Component +public class Payments { + /// The id and the label of the method every account has. + public static final String CASH = "cash"; + /// An invoice claimed for a charge whose outcome is not yet recorded. + public static final String CHARGING = "CHARGING"; + public static final String CASH_LABEL = "Cash"; + private static final int MAX_CARDS = 8; + /// A setup not finished within this long is abandoned. + private static final long SETUP_MILLIS = 3600000L; + /// How many setups an account may have open at once. Each is a session + /// made at the processor and a row here; someone adding a card has one, + /// or two after giving up on the first. + private static final int MAX_OPEN_SETUPS = 5; + + private final PaymentMethodRepository cards; + private final PaymentSetupRepository setups; + private final PaymentCustomerRepository customers; + private final InvoiceRepository invoices; + private final ProfileRepository profiles; + private final PaymentProvider provider; + + public Payments(PaymentMethodRepository cards, PaymentSetupRepository setups, + PaymentCustomerRepository customers, InvoiceRepository invoices, + ProfileRepository profiles, PaymentProvider provider) { + this.cards = cards; + this.setups = setups; + this.customers = customers; + this.invoices = invoices; + this.profiles = profiles; + this.provider = provider; + } + + public PaymentConfigDto config(String currency) { + return new PaymentConfigDto(provider.name(), currency); + } + + // --------------------------------------------------------------- methods + + /// The saved cards, oldest first, then cash. Exactly one of them is the + /// default: a card when one is marked, cash otherwise. + @Transactional(readOnly = true) + public List methods(String username) throws IOException { + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + List out = new ArrayList(); + boolean marked = false; + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethodDto dto = card(rows.get(iter)); + marked |= dto.isDefault(); + out.add(dto); + } + out.add(cash(!marked)); + return out; + } + + /// The id of the user's default method. + @Transactional(readOnly = true) + public String defaultMethod(String username) throws IOException { + PaymentMethod row = cards.findFirstByUsernameAndPreferredTrue(username); + return row == null ? CASH : row.getId(); + } + + /// Begins adding a card. + public PaymentSetupDto startSetup(String username, String displayName) throws IOException { + String customer = customerForSetup(username); + String id = Ids.next(); + PaymentProvider.Setup setup; + try { + if (customer == null) { + String created = provider.createCustomer(username, displayName); + customer = keepCustomer(username, created); + if (!created.equals(customer)) { + // Not kept: the account was closed while the processor was + // asked (its clean-up found no customer to delete, so this + // one is ours to delete), or another request kept its own + // first. Either way the record made here would otherwise + // stay at the processor with the customer's name on it. + forgetQuietly(created); + } + if (customer == null) { + throw new ResponseStatusException(404, "No such account"); + } + } + setup = provider.startCardSetup(customer, id); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + keepSetup(username, id, setup.reference); + return new PaymentSetupDto(id, setup.hosted, setup.url); + } + + /// The customer's reference at the processor, or null when they have none + /// yet. Answers 409 for an account that has all the cards it may, and + /// 429 for one with as many setups open as it may have. + @Transactional(readOnly = true) + private String customerForSetup(String username) { + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + // Refused before the processor is asked: every setup is a session + // made there, so a loop of these would otherwise make sessions + // without end in the service's name. + refuseTooManySetups(username); + PaymentCustomer row = customers.findById(username); + return row == null ? null : row.getReference(); + } + + /// Answers 429 when the account already has [#MAX_OPEN_SETUPS] setups + /// that are neither finished nor abandoned. + private void refuseTooManySetups(String username) { + if (setups.countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(username, + Instant.now().minusMillis(SETUP_MILLIS)) >= MAX_OPEN_SETUPS) { + throw new ResponseStatusException(429, + "Too many cards are being added. Finish one, or try again later"); + } + } + + /// Deletes the setups nobody can use any more: the finished ones, whose + /// card is saved, and the abandoned ones. Without it every setup ever + /// started would stay, one row for each tap of "Add card". + /// + /// An abandoned setup is kept for twice the time it can be finished in. + /// One finished just inside the hour is still at the processor when the + /// hour passes, and its row is what saves the card when the answer comes. + @Scheduled(fixedDelay = 600000, initialDelay = 60000, lock = "pulse-card-setups") + @Transactional + public void sweepSetups() throws IOException { + setups.deleteByCompletedTrue(); + setups.deleteByCreatedAtLessThan(Instant.now().minusMillis(2L * SETUP_MILLIS)); + } + + /// Keeps the reference the processor made for a customer on first use, and + /// returns the one the account has: another request may have kept its own + /// while this one was asking for it. Null when the account is gone. + @Transactional + private String keepCustomer(String username, String reference) { + // Two first cards started together both find no row. Taking turns on + // the account lets the second see the first's row and use it, where + // it would otherwise insert a second and fail on the key. With no + // profile to hold the account was closed meanwhile, and its clean-up + // has already run: a row kept now would never be deleted. + if (profiles.hold(username) == 0) { + return null; + } + PaymentCustomer row = customers.findById(username); + if (row == null) { + row = new PaymentCustomer(); + row.setUsername(username); + row.setReference(reference); + customers.insert(row); + } + return row.getReference(); + } + + @Transactional + private void keepSetup(String username, String id, String reference) { + // As in keepCustomer: an account closed while the processor was asked + // gets no row its clean-up has already missed. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // Counted again with the account held: several requests sent together + // all pass the count made before the processor was asked. + // + // The session a refusal here leaves at the processor is not cleaned + // up, on purpose. Its address is never answered, so nobody can open + // it; a card entered there anyway could not be saved, because saving + // claims this row and there is none; and the processor expires an + // unfinished session by itself. Only the requests already past the + // first count when the limit was reached can leave one, so a loop + // of them still makes no more than a handful. + refuseTooManySetups(username); + PaymentSetup row = new PaymentSetup(); + row.setId(id); + row.setUsername(username); + row.setReference(reference); + row.setCreatedAt(Instant.now()); + row.setCompleted(false); + setups.insert(row); + } + + /// Finishes adding a card and returns it. The first card an account saves + /// becomes its default: someone who adds a card means to pay with it. + /// Answers 402 when the card was refused, with the reason. + public PaymentMethodDto completeSetup(String username, String setupId, CardDto typed) + throws IOException { + String[] references = openSetup(username, setupId); + PaymentProvider.Card card; + try { + card = provider.finishCardSetup(references[0], references[1], typed); + } catch (PaymentDeclinedException declined) { + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + try { + return keepCard(username, setupId, Ids.next(), card); + } catch (ResponseStatusException refused) { + // The processor attached the card when the setup finished. Refused + // here by the card limit -- two setups finishing together for the + // last slot, or a slot filled meanwhile -- it has no row, so the + // customer has nothing to remove it through: it is detached here + // instead. keepCard has rolled back by now, so this runs outside + // its transaction and nothing undoes it. + // + // Undone after the fact rather than a slot reserved before the + // processor is asked: a reservation is one more row with a life of + // its own, given back when the processor declines, times out or + // the server stops between the two, and swept when nothing gives + // it back. A refusal is the rare case, and this costs only it. + // + // Only the limit's 409. The 404 is a setup claimed by a request + // sent twice, whose other copy may have saved this very card. + if (refused.getStatus() == 409) { + discard(username, references[0], card); + } + throw refused; + } + } + + /// Detaches a card the processor attached that this server did not keep. + /// A failure is logged and not answered: the customer's answer is the refusal + /// that brought us here, and the card is no worse off than before. + private void discard(String username, String customer, PaymentProvider.Card card) { + try { + provider.detachCard(customer, card.token); + } catch (IOException failed) { + System.err.println("a card refused for " + username + " is still attached at the " + + "processor, as " + card.token + ": " + failed.getMessage()); + } + } + + /// What the processor calls the customer and a setup of theirs that is still + /// open, in that order. Answers 404 for a setup that is not. + @Transactional(readOnly = true) + private String[] openSetup(String username, String setupId) { + PaymentSetup setup = setupId == null || setupId.length() == 0 || setupId.length() > 32 + ? null : setups.findFirstByIdAndUsername(setupId, username); + if (setup == null || setup.isCompleted() + || setup.getCreatedAt().plusMillis(SETUP_MILLIS).isBefore(Instant.now())) { + throw new ResponseStatusException(404, "No such card setup"); + } + PaymentCustomer customer = customers.findById(username); + return new String[] {customer == null ? "" : customer.getReference(), setup.getReference()}; + } + + /// Closes the setup and saves its card, together or not at all. + @Transactional + private PaymentMethodDto keepCard(String username, String setupId, String id, + PaymentProvider.Card card) { + // Claimed with a conditional update, so a request sent twice saves the + // card once. + if (setups.claim(setupId) != 1) { + throw new ResponseStatusException(404, "No such card setup"); + } + // Counted again here, where the card is saved: starting a setup checks + // the limit too, but several can be started while there is room for + // one. The account's row at the processor is written first, so two + // setups finishing together take turns and the second counts the + // first one's card. Refusing rolls the claim back with everything else. + customers.hold(username); + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + boolean first = cards.findFirstByUsernameAndPreferredTrue(username) == null; + // Cards are listed in the order they were added, and two added in the + // same millisecond would have no order: the later is stamped after + // the one before it. + Instant newest = cards.newestAt(username); + Instant now = Instant.now(); + Instant after = newest == null ? Instant.EPOCH : newest.plusMillis(1L); + PaymentMethod row = new PaymentMethod(); + row.setId(id); + row.setUsername(username); + row.setBrand(cut(card.brand, 24)); + row.setLast4(cut(card.last4, 4)); + row.setExpMonth(card.expMonth); + row.setExpYear(card.expYear); + row.setToken(card.token); + row.setPreferred(first); + row.setCreatedAt(after.isAfter(now) ? after : now); + cards.insert(row); + return card(row); + } + + /// Makes a method the one deliveries are paid with unless they say otherwise. + @Transactional + public PaymentMethodDto makeDefault(String username, String id) throws IOException { + // One at a time for an account: two choices made together would each + // clear the cards it had read and mark its own, and leave two marked. + profiles.hold(username); + PaymentMethod chosen = CASH.equals(id) ? null : owned(username, id); + // The cards are few, and each is written only if its mark changed. + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethod row = rows.get(iter); + row.setPreferred(chosen != null && row.getId().equals(chosen.getId())); + } + if (chosen == null) { + return cash(true); + } + chosen.setPreferred(true); + return card(chosen); + } + + /// Forgets a card, here and at the processor. Cash cannot be removed, and + /// neither can a card a charge is under way on. + /// + /// The card is detached at the processor before its row is deleted, and + /// inside the transaction that deletes it, as [#forget] deletes a + /// customer: the token is the only way back to the card, so a processor + /// that cannot be reached fails the removal and the card stays, to be + /// removed again. Detaching a card that is already detached succeeds, so + /// a removal whose commit failed after the processor answered finishes + /// the second time. The account's row is held across the request, which + /// holds back only that member's own payments, and removing a card is + /// rare enough for that. + @Transactional + public PaymentMethodDto remove(String username, String id) throws IOException { + if (CASH.equals(id)) { + throw new ResponseStatusException(400, "Cash is always available"); + } + // Billing holds the same row as it claims an invoice for a charge, so + // a charge and a removal sent together take turns: either the charge + // is there to be seen below, or it no longer finds the card. + profiles.hold(username); + PaymentMethod row = owned(username, id); + // What is answered is the card as it was, no longer anyone's default. + PaymentMethodDto dto = card(row, false); + // A charge whose answer never came is asked about again by the card's + // token; with the card gone the question could not be put. + if (invoices.existsByAccountAndMethodIdAndStatus(username, id, CHARGING)) { + throw new ResponseStatusException(409, + "That card is being charged. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + if (customer != null && customer.getReference().length() > 0) { + try { + provider.detachCard(customer.getReference(), row.getToken()); + } catch (IOException failed) { + // A runtime exception, so the transaction rolls back with it. + throw unavailable("card removal", failed); + } + } + cards.deleteById(row.getId()); + return dto; + } + + /// The method a payment is made with: the one named, or the default when + /// none is. Answers 400 for a method that is not the customer's. + /// + /// @return the method's id and its label, in that order + @Transactional(readOnly = true) + public String[] choose(String username, String wanted) throws IOException { + String id = wanted == null || wanted.trim().length() == 0 ? defaultMethod(username) + : wanted.trim(); + if (CASH.equals(id)) { + return new String[] {CASH, CASH_LABEL}; + } + PaymentMethod row = card(username, id); + if (row == null) { + throw new ResponseStatusException(400, "Choose a way to pay"); + } + return new String[] {id, label(row)}; + } + + /// Everything of a user's that this class keeps, for an account being + /// closed, here and at the processor. Answers 409 while a charge of theirs + /// is under way; see [#remove]. + /// + /// The processor is asked last, and inside the transaction that deletes + /// the rest. It holds the customer's address, their name and their cards, and + /// the reference deleted here is the only way back to them: forgotten + /// first, a request that then failed would leave that record with nothing + /// pointing at it. So a processor that cannot be reached fails the closure + /// whole and the account stays as it was, to be closed again. The other + /// way round -- the processor has deleted and the commit then fails -- is + /// mended by the same second attempt, since a customer already gone counts + /// as deleted. A closure is rare enough to wait on a request with a row + /// held, which nothing else in this class does. + @Transactional(rollbackFor = IOException.class) + public void forget(String username) throws IOException { + if (invoices.existsByAccountAndStatus(username, CHARGING)) { + throw new ResponseStatusException(409, + "A payment is under way. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + String reference = customer == null ? "" : customer.getReference(); + cards.deleteByUsername(username); + setups.deleteByUsername(username); + customers.deleteByUsername(username); + if (reference != null && reference.length() > 0) { + provider.forgetCustomer(reference); + } + } + + // ------------------------------------------------------------- internals + + /// Charges one of a member's saved cards and returns the processor's + /// reference. `key` makes the charge idempotent at the processor: asked + /// again with the same key, it answers the first charge and takes nothing + /// more. What the charge needs is read in a transaction that has ended by + /// the time the processor is called; call it with none open. + /// + /// @throws PaymentDeclinedException when the card was refused or is gone + public String charge(String key, String username, String methodId, long amountCents, + String currency, String description) throws IOException { + String[] references = chargeable(username, methodId); + if (references == null) { + throw new PaymentDeclinedException("The card is no longer on the account"); + } + return cut(provider.charge(key, references[0], references[1], amountCents, currency, + description), 64); + } + + /// What the processor calls the customer and one of their cards, in that + /// order, or null when either is gone. + @Transactional(readOnly = true) + private String[] chargeable(String username, String methodId) { + PaymentMethod method = card(username, methodId); + PaymentCustomer customer = customers.findById(username); + if (method == null || customer == null) { + return null; + } + return new String[] {customer.getReference(), method.getToken()}; + } + + private PaymentMethod owned(String username, String id) { + PaymentMethod row = card(username, id); + if (row == null) { + // Someone else's card is told apart from no card by nobody. + throw new ResponseStatusException(404, "No such payment method"); + } + return row; + } + + private static PaymentMethodDto card(PaymentMethod row) { + return card(row, row.isPreferred()); + } + + private static PaymentMethodDto card(PaymentMethod row, boolean isDefault) { + return new PaymentMethodDto(row.getId(), "card", row.getBrand(), row.getLast4(), + row.getExpMonth(), row.getExpYear(), isDefault, label(row)); + } + + private static PaymentMethodDto cash(boolean isDefault) { + return new PaymentMethodDto(CASH, "cash", "", "", 0, 0, isDefault, CASH_LABEL); + } + + private static String label(PaymentMethod method) { + return method.getBrand() + " " + method.getLast4(); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } + + /// Deletes a customer record this server made and did not keep. A failure + /// is logged rather than answered: the request has its own answer, and + /// the record holds no card. + private void forgetQuietly(String reference) { + try { + provider.forgetCustomer(reference); + } catch (IOException failed) { + System.err.println("could not delete an unused payment customer: " + + failed.getMessage()); + } + } + + /// A fault at the processor, as the app is told of it. What went wrong goes + /// to the log; it describes the request, never a card or a key. + private static ResponseStatusException unavailable(String what, IOException failed) { + System.err.println("payment provider failed during " + what + ": " + failed.getMessage()); + return new ResponseStatusException(502, "Payments are unavailable right now"); + } + + /// One of an account's cards, or null when the id is not a card of theirs. + private PaymentMethod card(String username, String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return cards.findFirstByIdAndUsername(id, username); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/SimulatedPayments.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/SimulatedPayments.java new file mode 100644 index 00000000000..ffb6db7767d --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/SimulatedPayments.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.CardDto; + +import java.io.IOException; + +/// A processor that moves no money. What the sample runs on until it is given +/// a real one's key, and what every test but that provider's own runs against. +/// +/// It behaves like the real thing where the app can tell the difference: a +/// card has to look like a card, and the well-known test numbers do what they +/// do at a real processor's test mode. +/// +/// - `4242 4242 4242 4242` is accepted, as is any other number that passes the +/// check digit. +/// - `4000 0000 0000 0002` is declined when it is added. +/// - `4000 0000 0000 0341` is accepted and then declined when it is charged, +/// which is how a payment that fails at the end of a delivery is tried out. +/// - `4000 0000 0000 0119` is accepted and then cannot be charged at all: the +/// processor fails without saying whether it took the money, which is how +/// a payment nobody can vouch for is tried out. +/// - `4000 0000 0000 7726` is charged like any other, and a refund of it is +/// pending when it is made and has succeeded when it is next asked about, +/// which is how a refund the bank has not answered yet is tried out. +/// +/// A refund that is pending and then fails has no card number: a card whose +/// token starts `sim_pm_lost_` does that, and only a test makes one. +/// +/// The card number is read here, in this method, and goes no further: the +/// brand and the last four digits are all that is returned or stored. +public class SimulatedPayments implements PaymentProvider { + static final String DECLINED_NUMBER = "4000000000000002"; + static final String FAILS_LATER_NUMBER = "4000000000000341"; + static final String FAULTY_NUMBER = "4000000000000119"; + static final String SLOW_REFUND_NUMBER = "4000000000007726"; + private static final String FAILING_TOKEN = "sim_pm_fail_"; + private static final String FAULTY_TOKEN = "sim_pm_fault_"; + private static final String SLOW_REFUND_TOKEN = "sim_pm_slow_"; + private static final String LOST_REFUND_TOKEN = "sim_pm_lost_"; + private static final String SLOW_REFUND_CHARGE = "sim_ch_slow_"; + private static final String LOST_REFUND_CHARGE = "sim_ch_lost_"; + private static final String FAILING_HOLD = "sim_hold_fail_"; + private static final String FAULTY_HOLD = "sim_hold_fault_"; + private static final String REFUND = "sim_re_"; + + @Override + public String name() { + return "simulated"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + return "sim_cus_" + Ids.next(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) { + Setup setup = new Setup(); + setup.reference = "sim_seti_" + setupId; + return setup; + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + if (typed == null) { + throw new PaymentDeclinedException("Enter the card's details"); + } + String number = digits(typed.number()); + if (number.length() < 12 || number.length() > 19 || !luhn(number)) { + throw new PaymentDeclinedException("That is not a card number"); + } + int year = typed.expYear() < 100 ? typed.expYear() + 2000 : typed.expYear(); + String today = Days.iso(System.currentTimeMillis()); + int thisYear = Integer.parseInt(today.substring(0, 4)); + int thisMonth = Integer.parseInt(today.substring(5, 7)); + if (typed.expMonth() < 1 || typed.expMonth() > 12 || year > thisYear + 25 + || year < thisYear || (year == thisYear && typed.expMonth() < thisMonth)) { + throw new PaymentDeclinedException("That card has expired"); + } + String cvc = typed.cvc() == null ? "" : typed.cvc().trim(); + if (cvc.length() < 3 || cvc.length() > 4 || digits(cvc).length() != cvc.length()) { + throw new PaymentDeclinedException("Check the card's security code"); + } + if (DECLINED_NUMBER.equals(number)) { + throw new PaymentDeclinedException("Your card was declined"); + } + Card card = new Card(); + card.brand = brand(number); + card.last4 = number.substring(number.length() - 4); + card.expMonth = typed.expMonth(); + card.expYear = year; + card.token = (FAILS_LATER_NUMBER.equals(number) ? FAILING_TOKEN + : FAULTY_NUMBER.equals(number) ? FAULTY_TOKEN + : SLOW_REFUND_NUMBER.equals(number) ? SLOW_REFUND_TOKEN : "sim_pm_") + Ids.next(); + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + // Nothing is kept of a card here; its token is all there is, and the + // server deletes that. + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + if (token.startsWith(FAILING_TOKEN)) { + throw new PaymentDeclinedException("Your card was declined"); + } + if (token.startsWith(FAULTY_TOKEN)) { + throw new IOException("The simulated processor did not answer"); + } + // A charge says how its refund is to go, as the card did: the refund + // has only the charge to go by. + return (token.startsWith(SLOW_REFUND_TOKEN) ? SLOW_REFUND_CHARGE + : token.startsWith(LOST_REFUND_TOKEN) ? LOST_REFUND_CHARGE : "sim_ch_") + key; + } + + /// A hold is a charge that has not been taken yet, and its reference + /// refunds the way the charge's would. A card that fails when charged is + /// held without complaint and fails when the hold is taken, as a hold that + /// lapsed while the order was on its way does: that is the failure an + /// order meets after checkout, and the one the tests have to reach. + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + if (token.startsWith(FAILING_TOKEN)) { + return FAILING_HOLD + key; + } + if (token.startsWith(FAULTY_TOKEN)) { + return FAULTY_HOLD + key; + } + return charge(key, customer, token, amountCents, currency, description); + } + + @Override + public void capture(String reference, long amountCents) throws IOException { + // Otherwise nothing to take: the simulated card holds no money. + if (reference.startsWith(FAILING_HOLD)) { + throw new PaymentDeclinedException("Your card was declined"); + } + if (reference.startsWith(FAULTY_HOLD)) { + throw new IOException("The simulated processor did not answer"); + } + } + + @Override + public void release(String reference) { + // Nothing to let go. + } + + @Override + public void forgetCustomer(String customer) { + // Nothing is kept of one here. + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + // Named after the charge, as a real processor's refund is keyed by + // it: asking twice is one refund, which is what lets a refund whose + // answer was lost be asked for again. + Refund refund = new Refund(); + refund.reference = REFUND + chargeReference; + refund.pending = chargeReference.startsWith(SLOW_REFUND_CHARGE) + || chargeReference.startsWith(LOST_REFUND_CHARGE); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundReference.startsWith(REFUND + LOST_REFUND_CHARGE) ? REFUND_FAILED + : REFUND_SUCCEEDED; + } + + /// The digits of what was typed, with the spaces and dashes people put in a + /// card number taken out. Anything else in it leaves it too short to pass. + private static String digits(String value) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; value != null && iter < value.length(); iter++) { + char c = value.charAt(iter); + if (c >= '0' && c <= '9') { + out.append(c); + } else if (c != ' ' && c != '-') { + return ""; + } + } + return out.toString(); + } + + /// The check digit every card number carries, which catches a mistyped one. + private static boolean luhn(String number) { + int sum = 0; + boolean doubled = false; + for (int iter = number.length() - 1; iter >= 0; iter--) { + int digit = number.charAt(iter) - '0'; + if (doubled) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + doubled = !doubled; + } + return sum % 10 == 0; + } + + private static String brand(String number) { + if (number.startsWith("4")) { + return "Visa"; + } + if (number.startsWith("34") || number.startsWith("37")) { + return "Amex"; + } + if (number.startsWith("6011") || number.startsWith("65")) { + return "Discover"; + } + int two = Integer.parseInt(number.substring(0, 2)); + if ((two >= 51 && two <= 55) || (two >= 22 && two <= 27)) { + return "Mastercard"; + } + return "Card"; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/StripePayments.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/StripePayments.java new file mode 100644 index 00000000000..720f779614e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/pay/StripePayments.java @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.api.CardDto; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Payments by Stripe, over its REST API. No SDK: a few requests, form encoded, +/// with the account's secret key as a bearer token. +/// +/// **This is an untested stub.** It has never been run against a live Stripe +/// account: the requests follow Stripe's published API, and the tests hold it +/// to canned answers through [Transport], but nothing here has seen a real +/// card, a bank asking its cardholder to approve a charge, or a refund settle. +/// Treat it as a starting point, and run it against a Stripe test-mode key +/// before any money depends on it. +/// +/// Selected with `pulse.payments.provider=stripe`. +/// +/// - **A customer** is made the first time a customer adds a card. +/// - **A card is added on Stripe's own page.** [#startCardSetup] opens a +/// Checkout Session in `setup` mode and hands back its address; the app +/// opens it in the browser. The card is typed there, so its number never +/// reaches the app or this server, and neither falls under the card +/// industry's rules for systems that handle one. +/// - **The setup is read back** when the app says the user returned: the +/// session's SetupIntent names the PaymentMethod Stripe saved, and its brand +/// and last four digits are all this server keeps beside its id. +/// - **A delivery is charged** with a PaymentIntent that is `off_session` and +/// confirmed in the same request, because the customer is in a car and not at a +/// payment form. +/// - **A refund** returns a charge to the card. +/// - **A card removed** in the app is detached from the customer, so Stripe +/// no longer offers it for a charge. +/// +/// The secret key is a setting of the server (`pulse.payments.stripe.secret`) +/// and is used for the `Authorization` header and nothing else: it is never +/// logged and never part of an error. +/// +/// Everything Stripe-specific is in [#form] strings and the reading of the +/// answers. The network is behind [Transport], which is what lets the tests +/// hold both to canned answers without a key. +public class StripePayments implements PaymentProvider { + private static final String API = "https://api.stripe.com"; + + private final Transport transport; + private final String returnUrl; + + /// @param secret the account's secret key + /// @param returnUrl the address of this server's `/pay/return` page, which + /// Stripe sends the browser to when the user is done + public StripePayments(String secret, String returnUrl) { + this(new WebTransport(secret), returnUrl); + } + + public StripePayments(Transport transport, String returnUrl) { + this.transport = transport; + this.returnUrl = returnUrl; + } + + @Override + public String name() { + return "stripe"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + // Two first cards started together both find the account without a + // customer and both ask for one. Only one reference is kept, and the + // other would be left at the processor with nothing pointing at it. + // With the same key the second request is answered with the first + // one's customer. The key is a digest, since the header is logged in + // places an address should not be, and it covers the name too: the + // processor refuses a key it has seen with other fields, and a customer + // who renamed themselves between two attempts must not be refused. + // The price is one orphan in a case that takes two mishaps: the + // answer to the first attempt is lost, and the customer renames + // themselves before trying again. The second customer is the one + // kept; the first holds no card and no money, and is left at the + // processor rather than looked up by address on every first card. + String key = "customer-" + Ids.sha256(username + "\n" + displayName); + Map made = ask("POST", "/v1/customers", form("email", username, "name", displayName, + "metadata[pulse_user]", username), key); + return id(made); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + // Deleting a customer detaches its payment methods too. 404 is the + // answer for one deleted by a request whose answer never arrived. + Reply reply = transport.send("DELETE", "/v1/customers/" + + URLEncoder.encode(customer, "UTF-8"), null, ""); + if (reply.status != 404 && (reply.status < 200 || reply.status >= 300)) { + throw new IOException("The payment processor answered " + reply.status + + " to deleting a customer"); + } + } + + // tag::pulse-payment-hosted[] + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + Map session = ask("POST", "/v1/checkout/sessions", form("mode", "setup", + "customer", customer, "payment_method_types[0]", "card", + "client_reference_id", setupId, + "success_url", returnUrl + "?result=done", + "cancel_url", returnUrl + "?result=cancelled"), ""); + Setup setup = new Setup(); + setup.hosted = true; + setup.reference = id(session); + setup.url = text(session, "url"); + if (setup.url.length() == 0) { + throw new IOException("The payment processor returned no page to open"); + } + return setup; + } + // end::pulse-payment-hosted[] + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + Map session = ask("GET", "/v1/checkout/sessions/" + URLEncoder.encode(reference, "UTF-8") + + "?expand%5B%5D=setup_intent.payment_method", null, ""); + Object intent = session.get("setup_intent"); + Object method = intent instanceof Map ? ((Map) intent).get("payment_method") : null; + if (!"complete".equals(text(session, "status")) || !(intent instanceof Map) + || !"succeeded".equals(text((Map) intent, "status")) || !(method instanceof Map)) { + throw new PaymentDeclinedException("The card was not added. Try again."); + } + // The session is looked up by a reference this server stored for this + // customer, so this cannot differ -- unless something is very wrong, in + // which case the card is not attached to anybody. + if (!customer.equals(text(session, "customer"))) { + throw new IOException("The payment processor's session is for another customer"); + } + Card card = new Card(); + card.token = id((Map) method); + Object details = ((Map) method).get("card"); + if (details instanceof Map) { + card.brand = brand(text((Map) details, "brand")); + card.last4 = text((Map) details, "last4"); + card.expMonth = (int) number((Map) details, "exp_month"); + card.expYear = (int) number((Map) details, "exp_year"); + } + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + String path = "/v1/payment_methods/" + URLEncoder.encode(token, "UTF-8"); + Reply reply = transport.send("POST", path + "/detach", "", ""); + if ((reply.status >= 200 && reply.status < 300) || reply.status == 404) { + // Detached, or no such card: 404 is the answer for one deleted + // with its customer, or never there. + return; + } + // Stripe refuses to detach a card that is attached to nobody, which + // is what a second request finds after the first one's answer was + // lost. Rather than read that from an error's wording, the card is + // looked at: no longer this customer's, it is detached. + Reply now = transport.send("GET", path, null, ""); + if (now.status == 404) { + return; + } + if (now.status >= 200 && now.status < 300) { + Map method; + try { + method = Json.parseObject(now.body == null ? "" : now.body); + } catch (IOException unreadable) { + method = null; + } + if (method != null && !customer.equals(text(method, "customer"))) { + return; + } + } + throw new IOException("The payment processor answered " + reply.status + + " to detaching a card"); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + Map intent = ask("POST", "/v1/payment_intents", form("amount", String.valueOf(amountCents), + "currency", asciiLower(currency), "customer", customer, "payment_method", token, + "off_session", "true", "confirm", "true", "description", description, + "metadata[pulse_charge]", key), key); + if (!"succeeded".equals(text(intent, "status"))) { + // Most often `requires_action`: the bank wants the cardholder to + // approve it, and there is nobody at a screen to do so. + throw new PaymentDeclinedException("The card needs its owner's approval"); + } + return id(intent); + } + + /// A PaymentIntent with `capture_method=manual`: confirmed now, it ends in + /// `requires_capture` with the money held on the card. + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + Map intent = ask("POST", "/v1/payment_intents", form("amount", String.valueOf(amountCents), + "currency", asciiLower(currency), "customer", customer, "payment_method", token, + "off_session", "true", "confirm", "true", "capture_method", "manual", + "description", description, "metadata[pulse_charge]", key), key); + if (!"requires_capture".equals(text(intent, "status"))) { + throw new PaymentDeclinedException("The card needs its owner's approval"); + } + return id(intent); + } + + /// Asked again after a lost answer, Stripe refuses to capture an intent + /// twice; so the intent is read first and one already `succeeded` is done. + @Override + public void capture(String reference, long amountCents) throws IOException { + String path = "/v1/payment_intents/" + URLEncoder.encode(reference, "UTF-8"); + if ("succeeded".equals(text(ask("GET", path, null, null), "status"))) { + return; + } + Map intent = ask("POST", path + "/capture", form("amount_to_capture", + String.valueOf(amountCents)), "capture-" + reference); + if (!"succeeded".equals(text(intent, "status"))) { + throw new IOException("The hold was not captured: " + text(intent, "status")); + } + } + + @Override + public void release(String reference) throws IOException { + String path = "/v1/payment_intents/" + URLEncoder.encode(reference, "UTF-8"); + if ("canceled".equals(text(ask("GET", path, null, null), "status"))) { + return; + } + ask("POST", path + "/cancel", form(), "release-" + reference); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + Map answer = ask("POST", "/v1/refunds", form("payment_intent", chargeReference, + "amount", String.valueOf(amountCents)), "refund-" + chargeReference); + String status = refundState(text(answer, "status")); + if (REFUND_FAILED.equals(status)) { + throw new IOException("The refund was not accepted: " + text(answer, "status")); + } + Refund refund = new Refund(); + refund.reference = id(answer); + // A pending refund can still fail or be canceled, so it is not yet a + // refund to record as made: the caller keeps asking. + refund.pending = REFUND_PENDING.equals(status); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundState(text(ask("GET", "/v1/refunds/" + + URLEncoder.encode(refundReference, "UTF-8"), null, null), "status")); + } + + /// Stripe's five refund states as three. `requires_action` waits on the + /// cardholder like `pending` waits on the bank, and `canceled` returned no + /// more money than `failed` did. A state this does not know is taken as + /// still pending: asked about again, it is never recorded as money back. + private static String refundState(String stripe) { + if ("succeeded".equals(stripe)) { + return REFUND_SUCCEEDED; + } + if ("failed".equals(stripe) || "canceled".equals(stripe)) { + return REFUND_FAILED; + } + return REFUND_PENDING; + } + + /// Makes a request and returns the object in the answer. + /// + /// A card Stripe refuses is answered with status 402 and an error of type + /// `card_error`, whose message is written for the cardholder; that becomes + /// a [PaymentDeclinedException]. Any other failure is a fault of ours or + /// theirs and is reported by status and code only. + private Map ask(String method, String path, String form, String key) throws IOException { + Reply reply = transport.send(method, path, form, key); + Map body; + try { + body = Json.parseObject(reply.body == null ? "" : reply.body); + } catch (IOException unreadable) { + throw new IOException("The payment processor answered " + reply.status + + " with something that is not JSON"); + } + if (reply.status >= 200 && reply.status < 300) { + return body; + } + Object error = body.get("error"); + if (error instanceof Map) { + Map details = (Map) error; + if ("card_error".equals(text(details, "type"))) { + String message = text(details, "message"); + throw new PaymentDeclinedException(message.length() > 0 ? message + : "Your card was declined"); + } + throw new IOException("The payment processor answered " + reply.status + " " + + text(details, "type") + " " + text(details, "code")); + } + throw new IOException("The payment processor answered " + reply.status); + } + + /// A form body from names and values in turn. Both are escaped: a name + /// such as `metadata[pulse_user]` has brackets in it. A space is written + /// as `+`, which a form body allows. + static String form(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1] == null ? "" : pairs[iter + 1], + "UTF-8")); + } + return out.toString(); + } + + private static String id(Map object) throws IOException { + String id = text(object, "id"); + if (id.length() == 0) { + throw new IOException("The payment processor's answer has no id"); + } + return id; + } + + private static String text(Map object, String key) { + Object value = object.get(key); + return value instanceof String ? (String) value : ""; + } + + private static long number(Map object, String key) { + Object value = object.get(key); + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// Stripe names brands in lower case (`visa`, `mastercard`, `amex`); this is + /// the name with its first letter raised, which is how a person writes it. + private static String brand(String name) { + if (name.length() == 0) { + return "Card"; + } + char first = name.charAt(0); + if (first >= 'a' && first <= 'z') { + first = (char) (first - ('a' - 'A')); + } + return first + name.substring(1); + } + + /// Stripe wants a currency code in lower case. Folded by hand, because + /// `toLowerCase()` follows the server's locale and a currency code must not. + private static String asciiLower(String text) { + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + if (chars[iter] >= 'A' && chars[iter] <= 'Z') { + chars[iter] = (char) (chars[iter] + ('a' - 'A')); + } + } + return new String(chars); + } + + /// The network, as this class uses it: one request to the API, one answer. + public interface Transport { + /// @param path the path and query, from `/v1` + /// @param form the form-encoded body, or null for a request without one + /// @param idempotencyKey sent as `Idempotency-Key` when not empty, so a + /// request repeated with the same key is carried out once + Reply send(String method, String path, String form, String idempotencyKey) + throws IOException; + } + + /// An answer: its status and its body, whatever the status. + public static final class Reply { + public final int status; + public final String body; + + public Reply(int status, String body) { + this.status = status; + this.body = body; + } + } + + /// The real network: HTTPS to `api.stripe.com`, as [Web] makes it. + static final class WebTransport implements Transport { + private final String secret; + + WebTransport(String secret) { + this.secret = secret; + } + + @Override + public Reply send(String method, String path, String form, String idempotencyKey) + throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Bearer " + secret); + if (form != null) { + headers.add("Content-Type: application/x-www-form-urlencoded"); + } + if (idempotencyKey != null && idempotencyKey.length() > 0) { + headers.add("Idempotency-Key: " + idempotencyKey); + } + Web.Result answer = Web.request(method, API + path, headers, + form == null ? null : form.getBytes("UTF-8")); + return new Reply(answer.getStatus(), answer.getBodyAsString()); + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/CloudPushSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/CloudPushSender.java new file mode 100644 index 00000000000..7eef84ebe63 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/CloudPushSender.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.Tasks; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::pulse-push-cloud[] +/// Sends through the Codename One push service, which delivers to Firebase, +/// Apple, Huawei and the browsers on the app's behalf. +/// +/// The call to the service is made on a thread of its own, so the request that +/// changed a delivery answers without waiting for it, and a refusal is logged and +/// not thrown: the customer is still told over the live channel and on their next +/// look at the delivery. +public class CloudPushSender implements PushSender { + private final CloudPush push; + + /// @param push the client for the service, holding the app's id and the + /// server key + public CloudPushSender(CloudPush push) { + this.push = push; + } + + @Override + public void send(List targets, final PushNotification message, + final Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + final List to = new ArrayList(targets); + Tasks.platform(() -> deliver(to, message, delivered)); + } + + private void deliver(List to, PushNotification message, Runnable delivered) { + boolean accepted = false; + try { + PushResult result = push.send(message, to); + accepted = result.isAccepted(); + if (!accepted) { + System.err.println("[push] the push service refused a notification: " + + result.getStatus() + " " + result.getError()); + } + } catch (IOException | RuntimeException err) { + System.err.println("[push] a notification was not sent: " + err); + } + if (accepted && delivered != null) { + try { + delivered.run(); + } catch (RuntimeException err) { + System.err.println("[push] a notification was sent, and what follows it failed: " + + err); + } + } + } +} +// end::pulse-push-cloud[] diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/ConsolePushSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/ConsolePushSender.java new file mode 100644 index 00000000000..895ce4d87e0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/ConsolePushSender.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// Prints each notification on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.pulse.Services] hands it +/// out on a development profile alone. +public class ConsolePushSender implements PushSender { + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + System.out.println("[push] to " + targets.size() + " device(s): " + + message.toJson()); + // Printed is as delivered as this sender gets. + if (delivered != null) { + delivered.run(); + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/MisconfiguredPushSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/MisconfiguredPushSender.java new file mode 100644 index 00000000000..14f4e4d6eea --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/MisconfiguredPushSender.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// What the server sends with when the push settings are incomplete: nothing +/// goes out, and the log says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings to fix. Unlike a text message, a notification that does +/// not go out costs nobody a sign-in, so it is not an error to the caller. +public class MisconfiguredPushSender implements PushSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPushSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets != null && !targets.isEmpty()) { + System.err.println("[config] a push notification was not sent: " + problem); + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/Notifier.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/Notifier.java new file mode 100644 index 00000000000..82a107ba19b --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/Notifier.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Preferences; +import com.codenameone.examples.pulse.domain.Nudge; +import com.codenameone.examples.pulse.domain.Preference; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.repository.PreferenceRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; + +/// Everything the studio tells a member, in one place: each message is kept +/// in the member's inbox (a [Nudge] row, which is also what campaign +/// statistics count) and pushed to their devices when they want it. +/// +/// Two kinds of message, treated differently: +/// +/// - **Service**: a class was cancelled, a waitlist place came through. The +/// member needs it, so it is pushed whenever they allow class +/// notifications, quiet hours or not. +/// - **Marketing**: a class needs filling, we miss you, a campaign. Pushed +/// only when they allow promotions and outside their quiet hours; kept in +/// the inbox either way, so the app shows it next time it is opened. +@Component +public class Notifier { + /// Message kinds. The marketing ones are those [#marketing] answers for. + public static final String CANCELLED = "CANCELLED"; + public static final String PROMOTED = "PROMOTED"; + public static final String FILL = "FILL"; + public static final String MISS_YOU = "MISS_YOU"; + public static final String CAMPAIGN = "CAMPAIGN"; + public static final String INVITE = "INVITE"; + + private final NudgeRepository nudges; + private final PreferenceRepository preferences; + private final PushDevices devices; + private final PushSender sender; + private final ZoneId zone; + + public Notifier(NudgeRepository nudges, PreferenceRepository preferences, + PushDevices devices, PushSender sender, + @Value("${pulse.timezone:America/New_York}") String zone) { + this.nudges = nudges; + this.preferences = preferences; + this.devices = devices; + this.sender = sender; + this.zone = ZoneId.of(zone); + } + + public static boolean marketing(String kind) { + return FILL.equals(kind) || MISS_YOU.equals(kind) || CAMPAIGN.equals(kind); + } + + /// Whether a marketing message may be pushed to `account` now. False also + /// when the member turned promotions off. + @Transactional(readOnly = true) + public boolean reachable(String account, Instant now) { + Preference row = preferences.findById(account); + if (row == null) { + return !Preferences.quiet(Preferences.QUIET_FROM, Preferences.QUIET_TO, + now.atZone(zone).getHour()); + } + return row.isNotifyPromotions() + && !Preferences.quiet(row.getQuietFrom(), row.getQuietTo(), now.atZone(zone).getHour()); + } + + /// Records a message for `account` and pushes it when [#marketing] and + /// the member's preferences allow. Must be called inside a transaction; + /// the push itself leaves after the call returns, from the sender's own + /// thread, and a push that fails loses nothing -- the inbox has it. + public Nudge tell(String account, String kind, String campaignId, String sessionId, + String title, String body, Instant now) throws IOException { + Nudge row = new Nudge(); + row.setId(Ids.next()); + row.setAccount(account); + row.setKind(kind); + row.setCampaignId(campaignId == null ? "" : campaignId); + row.setSessionId(sessionId == null ? "" : sessionId); + row.setTitle(cut(title, 120)); + row.setBody(cut(body, 400)); + row.setSentAt(now); + nudges.insert(row); + Preference wants = preferences.findById(account); + boolean push = marketing(kind) ? reachable(account, now) + : wants == null || wants.isNotifyClasses(); + if (push) { + List to = devices.targets(account); + if (!to.isEmpty()) { + PushNotification.Builder message = PushNotification.builder().id(row.getId()) + .title(row.getTitle()).body(row.getBody()).data("kind", kind); + if (row.getSessionId().length() > 0) { + message.data("sessionId", row.getSessionId()) + .deepLink("pulse://class/" + row.getSessionId()); + } + sender.send(to, message.build(), null); + } + } + return row; + } + + private static String cut(String text, int max) { + String value = text == null ? "" : text; + return value.length() > max ? value.substring(0, max) : value; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushDevices.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushDevices.java new file mode 100644 index 00000000000..063c508a4ae --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushDevices.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.Text; +import com.codenameone.examples.pulse.api.PushDeviceDto; +import com.codenameone.examples.pulse.domain.PushDevice; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.PushDeviceRepository; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +// tag::pulse-push-devices[] +/// The devices each account is sent notifications on. +/// +/// The app registers its token when somebody signs in and removes it when they +/// sign out. A token registered by a second account moves to it: the phone +/// belongs to whoever signed in on it last, and the one before is not to be +/// told about the new owner's deliveries. +@Component +public class PushDevices { + /// How many devices one account keeps. A new one past this replaces the + /// one heard from longest ago: a token the platform has replaced is never + /// removed by its app, and would otherwise be sent to for good. + static final int MAX_PER_ACCOUNT = 8; + + private final PushDeviceRepository devices; + private final ProfileRepository profiles; + + public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) { + this.devices = devices; + this.profiles = profiles; + } + + @Transactional + public PushDeviceDto register(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + String id = key(target); + long now = System.currentTimeMillis(); + // Before anything is read, the account's profile row is held: a no-op + // update that answers 0 when the profile is gone and otherwise keeps + // the row locked until this commits. It settles two races. + // + // Against closing the account: Accounts.erase holds the same row + // first and deletes it before it commits, so a registration either + // runs first (and erase's cleanup then deletes the device with the + // rest) or waits, finds no profile and is refused. Without it, a + // registration authorized just before the closure could insert its + // device after erase removed the account's, leaving a device of an + // account that no longer exists and that nobody can unregister. + // + // Against itself: two devices registering at once for an account one + // short of the cap would both count the same rows in makeRoom(), both + // skip the eviction and both insert, one past the cap. Behind the hold + // the second counts after the first committed, and evicts. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The device row itself is not locked. Two accounts registering one + // token at the same moment -- a registration of the account that + // signed out still in flight as the next one signs in -- can leave it + // with the earlier account. The app settles it: a registration that + // answers after its account signed out is sent again for the account + // signed in now, and that one, sent after the stale one committed, + // lands last (Notifications.signedIn). + PushDevice row = devices.findById(id); + if (row == null || !username.equals(row.getUsername())) { + // A device new to this account, whether its token is new or moves + // here from another account, takes a place under the cap. + makeRoom(username); + } + if (row == null) { + row = new PushDevice(); + row.setId(id); + row.setProvider(target.getProvider()); + row.setToken(target.getToken()); + fill(row, username, device, now); + devices.insert(row); + } else { + fill(row, username, device, now); + } + return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform()); + } + + /// Removes `username`'s devices heard from longest ago until one more fits + /// under [#MAX_PER_ACCOUNT]. + private void makeRoom(String username) { + List held = new ArrayList(devices.findByUsername(username)); + while (held.size() >= MAX_PER_ACCOUNT) { + PushDevice oldest = held.get(0); + for (PushDevice each : held) { + if (each.getUpdatedAt().isBefore(oldest.getUpdatedAt())) { + oldest = each; + } + } + devices.deleteById(oldest.getId()); + held.remove(oldest); + } + } + + /// Forgets a device, if it is this account's. Somebody else's is left alone. + @Transactional + public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + devices.deleteByIdAndUsername(key(target), username); + return new PushDeviceDto(target.getProvider(), target.getToken(), ""); + } + + /// Where to reach `username`: every device they registered. + @Transactional(readOnly = true) + public List targets(String username) { + return targetsOf(username); + } + + /// [#targets] for a caller already inside a transaction. + List targetsOf(String username) { + List out = new ArrayList(); + if (username == null || username.length() == 0) { + return out; + } + for (PushDevice each : devices.findByUsername(username)) { + out.add(PushTarget.of(each.getProvider(), each.getToken())); + } + return out; + } + // end::pulse-push-devices[] + + private static void fill(PushDevice row, String username, PushDeviceDto device, long now) { + row.setUsername(username); + row.setPlatform(Text.optional(device.platform(), 32, "the platform")); + row.setUpdatedAt(Instant.ofEpochMilli(now)); + } + + /// The device as a target of the push service, which also spells its + /// provider the one way: `gcm` and `fcm` are one device. + private static PushTarget target(PushDeviceDto device) { + if (device == null) { + throw new ResponseStatusException(400, "Tell us the device"); + } + String token = Text.required(device.token(), 1024, "the device's token"); + try { + return PushTarget.of(Text.required(device.provider(), 16, "the push provider"), token); + } catch (IllegalArgumentException err) { + throw new ResponseStatusException(400, err.getMessage()); + } + } + + private static String key(PushTarget target) throws IOException { + return Ids.sha256(target.getProvider() + ":" + target.getToken()); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushSender.java new file mode 100644 index 00000000000..d5fb7b2afa5 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/PushSender.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +// tag::pulse-push-sender[] +/// Sends push notifications to devices. +/// +/// [com.codenameone.examples.pulse.Services] chooses the implementation from +/// `pulse.push.provider`. A notification is a courtesy on top of what the +/// app reads from the server, so sending one never fails the delivery it is about: +/// an implementation logs what went wrong and returns. +public interface PushSender { + /// Sends `message` to every one of `targets`. Returns at once; the sending + /// itself may happen later, on another thread. + /// + /// @param delivered run once the push service has taken the message, on + /// whatever thread learnt it; never when it was refused or could not be + /// sent. Null when nobody needs to know. + void send(List targets, PushNotification message, Runnable delivered); +} +// end::pulse-push-sender[] diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/RecordingPushSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/RecordingPushSender.java new file mode 100644 index 00000000000..810cb5675ef --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/push/RecordingPushSender.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Keeps every notification instead of sending it, for the tests to read. +/// +/// What the `test` profile gets: a test signs a phone in, drives a delivery, and +/// asks this what each device was sent. Refused on any profile that is not a +/// development one. +public class RecordingPushSender implements PushSender { + private final List sent = new ArrayList(); + private int refusals; + private int unconfirmed; + + /// One notification as one device was sent it. + public static final class Sent { + private final String provider; + private final String token; + private final Map message; + + Sent(String provider, String token, Map message) { + this.provider = provider; + this.token = token; + this.message = message; + } + + public String provider() { + return provider; + } + + public String token() { + return token; + } + + /// The notification as it would go on the wire: `title`, `body`, + /// `data`, `surface` and the rest. + public Map message() { + return message; + } + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + // Outside the lock: what follows a delivery writes to the database. + if (record(targets, message) && delivered != null) { + delivered.run(); + } + } + + /// Records a send, and answers whether the push service would confirm it. + private synchronized boolean record(List targets, PushNotification message) { + if (refusals > 0) { + refusals--; + throw new IllegalStateException("The push provider is unavailable"); + } + if (targets != null) { + for (PushTarget target : targets) { + sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap())); + } + } + if (unconfirmed > 0) { + unconfirmed--; + return false; + } + return true; + } + + /// What the device with `token` was sent, oldest first. + public synchronized List sentTo(String token) { + List out = new ArrayList(); + for (Sent each : sent) { + if (each.token.equals(token)) { + out.add(each); + } + } + return out; + } + + /// Everything sent so far, oldest first. + public synchronized List all() { + return new ArrayList(sent); + } + + public synchronized void clear() { + sent.clear(); + refusals = 0; + unconfirmed = 0; + } + + /// Makes the next `count` sends go out and never be confirmed, as a + /// service that refuses them later, on its own thread, does. + public synchronized void leaveUnconfirmed(int count) { + unconfirmed = count; + } + + /// Makes the next `count` sends throw, as a provider that is down does. + public synchronized void refuse(int count) { + refusals = count; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/AwayPeriodRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/AwayPeriodRepository.java new file mode 100644 index 00000000000..e70144159c6 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/AwayPeriodRepository.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.AwayPeriod; + +import java.time.LocalDate; +import java.util.List; + +@Repository(entity = AwayPeriod.class, key = String.class) +public interface AwayPeriodRepository extends CrudRepository { + List findByAccountAndEndsOnGreaterThanEqualOrderByStartsOnAsc(String account, + LocalDate from); + + AwayPeriod findFirstByIdAndAccount(String id, String account); + + /// Whether an account is away on a day. + boolean existsByAccountAndStartsOnLessThanEqualAndEndsOnGreaterThanEqual(String account, + LocalDate day, LocalDate sameDay); + + List findByStartsOnLessThanEqualAndEndsOnGreaterThanEqual(LocalDate day, + LocalDate sameDay); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/BookingRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/BookingRepository.java new file mode 100644 index 00000000000..1c22381ca24 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/BookingRepository.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Booking; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Booking.class, key = String.class) +public interface BookingRepository extends CrudRepository { + Booking findFirstBySessionIdAndMemberId(String sessionId, String memberId); + + Booking findFirstByIdAndAccount(String id, String account); + + List findBySessionIdOrderByCreatedAtAsc(String sessionId); + + List findBySessionIdAndStateOrderByQueuePositionAsc(String sessionId, String state); + + List findByAccountAndStateIn(String account, List states); + + List findBySessionIdIn(List sessionIds); + + List findByMemberIdAndStateOrderByCheckedInAtDesc(String memberId, String state); + + long countByMemberIdAndState(String memberId, String state); + + long countByAccountAndState(String account, String state); + + /// The last place in the queue handed out, so the next is one more. + @Query("select max(b.queuePosition) from Booking b where b.sessionId = :session") + Long lastPosition(@Param("session") String sessionId); + + /// The member's place in a class starting between two times, for the door. + @Query("select b from Booking b where b.memberId = :member and b.state = 'BOOKED'" + + " and b.sessionId in (select s.id from ClassSession s where s.startsAt >= :from" + + " and s.startsAt < :to)") + List bookedBetween(@Param("member") String memberId, @Param("from") Instant from, + @Param("to") Instant to); + + /// Which class types an account's household has been to, for nudges. + @Query("select distinct s.classTypeId from ClassSession s where s.id in (select b.sessionId" + + " from Booking b where b.account = :account and b.state = 'CHECKED_IN')") + List typesAttended(@Param("account") String account); + + /// Accounts that have checked in to a class of this type. + @Query("select distinct b.account from Booking b where b.state = 'CHECKED_IN' and b.sessionId" + + " in (select s.id from ClassSession s where s.classTypeId = :type)") + List accountsWhoTook(@Param("type") String classTypeId); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CampaignRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CampaignRepository.java new file mode 100644 index 00000000000..93588f983e7 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CampaignRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Campaign; + +import java.util.List; + +@Repository(entity = Campaign.class, key = String.class) +public interface CampaignRepository extends CrudRepository { + @Query("select c from Campaign c order by c.createdAt desc") + List findAllByOrderByCreatedAtDesc(); + + @Modifying + @Query("update Campaign c set c.opened = c.opened + 1 where c.id = :id") + int countOpen(@Param("id") String id); + + @Modifying + @Query("update Campaign c set c.booked = c.booked + 1 where c.id = :id") + int countBooking(@Param("id") String id); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CheckInRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CheckInRepository.java new file mode 100644 index 00000000000..61828c5a93f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CheckInRepository.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.CheckIn; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = CheckIn.class, key = String.class) +public interface CheckInRepository extends CrudRepository { + long countByMemberIdAndAtGreaterThanEqual(String memberId, Instant since); + + long countByAccount(String account); + + long countByAccountAndAtGreaterThanEqual(String account, Instant since); + + long countByLocationIdAndAtGreaterThanEqual(String locationId, Instant since); + + long countByAtGreaterThanEqual(Instant since); + List findByAtGreaterThanEqual(Instant since); + List findByMemberIdAndAtGreaterThanEqualOrderByAtAsc(String memberId, Instant since); + /// When each member, not account, last came in: a child and their parent + /// share an account. + @Query("select c.memberId, max(c.at) from CheckIn c group by c.memberId") + List lastVisitsByMember(); + + List findByAccountAndAtGreaterThanEqualOrderByAtAsc(String account, Instant since); + + List findByLocationIdAndAtGreaterThanEqualOrderByAtDesc(String locationId, + Instant since); + + /// When each account last came in. + @Query("select c.account, max(c.at) from CheckIn c group by c.account") + List lastVisits(); + + @Query("select max(c.at) from CheckIn c where c.account = :account") + Instant lastVisit(@Param("account") String account); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassSessionRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassSessionRepository.java new file mode 100644 index 00000000000..98dc1ec8b12 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassSessionRepository.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.ClassSession; + +import java.time.Instant; +import java.util.List; + +/// The timetable. +/// +/// The places taken in a class are a counter on its row, and the counter is +/// only ever moved by the two conditional updates below. A booking is a place +/// when [#takePlace] changed a row and a queue entry when it did not: the +/// database decides which, one statement at a time, so two people asking for +/// the last place at once cannot both get it, whatever else is in flight. +@Repository(entity = ClassSession.class, key = String.class) +public interface ClassSessionRepository extends CrudRepository { + List findByLocationIdAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + String locationId, Instant from, Instant to); + + List findByTrainerAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + String trainer, Instant from, Instant to); + + List findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + Instant from, Instant to); + + List findByIdIn(List ids); + + boolean existsByTrainerAndStateAndStartsAtGreaterThan(String trainer, String state, + Instant after); + + /// Classes in a room or with a trainer that overlap `[from, to)`. + @Query("select count(s) from ClassSession s where s.state = 'SCHEDULED' and s.startsAt < :to" + + " and s.endsAt > :from and (s.roomId = :room or s.trainer = :trainer)") + Long clashes(@Param("room") String room, @Param("trainer") String trainer, + @Param("from") Instant from, @Param("to") Instant to); + + /// Takes a place if there is one. 1 when it did, 0 when the class is full + /// or no longer on. + @Modifying + @Query("update ClassSession s set s.booked = s.booked + 1 where s.id = :id" + + " and s.booked < s.capacity and s.state = 'SCHEDULED'") + int takePlace(@Param("id") String id); + + @Modifying + @Query("update ClassSession s set s.booked = s.booked - 1 where s.id = :id and s.booked > 0") + int givePlace(@Param("id") String id); + + @Modifying + @Query("update ClassSession s set s.waitlisted = s.waitlisted + :delta where s.id = :id" + + " and s.waitlisted + :delta >= 0") + int moveQueue(@Param("id") String id, @Param("delta") int delta); + + /// Holds the class's row until the transaction ends, so that changes to + /// its queue are made one at a time. + @Modifying + @Query("update ClassSession s set s.waitlisted = s.waitlisted where s.id = :id") + int hold(@Param("id") String id); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassTypeRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassTypeRepository.java new file mode 100644 index 00000000000..7836074e8cb --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClassTypeRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.ClassType; + +import java.util.List; + +@Repository(entity = ClassType.class, key = String.class) +public interface ClassTypeRepository extends CrudRepository { + @Query("select t from ClassType t order by t.name asc") + List findAllByOrderByNameAsc(); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClosedAccountRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClosedAccountRepository.java new file mode 100644 index 00000000000..cdc9a3a7c3d --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ClosedAccountRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.ClosedAccount; + +/// The hashes of the e-mails that had an account once and may not have another. +@Repository(entity = ClosedAccount.class, key = String.class) +public interface ClosedAccountRepository extends CrudRepository { + +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CreditRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CreditRepository.java new file mode 100644 index 00000000000..455c3f53933 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/CreditRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Credit; + +import java.util.List; + +@Repository(entity = Credit.class, key = String.class) +public interface CreditRepository extends CrudRepository { + List findByAccountAndInvoiceIdOrderByCreatedAtAsc(String account, String invoiceId); + + boolean existsByAccountAndReason(String account, String reason); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/FriendRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/FriendRepository.java new file mode 100644 index 00000000000..5e8658048ac --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/FriendRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Friend; + +import java.util.List; + +/// Friendships, one row per direction: `account` asked `friend`, or accepted +/// them. Two rows, both ACCEPTED, are a friendship. +@Repository(entity = Friend.class, key = String.class) +public interface FriendRepository extends CrudRepository { + List findByAccount(String account); + + List findByFriend(String friend); + + List findByAccountAndState(String account, String state); + + int deleteByAccount(String account); + + int deleteByFriend(String friend); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/InvoiceRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/InvoiceRepository.java new file mode 100644 index 00000000000..a84762ee7df --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/InvoiceRepository.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Invoice; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Invoice.class, key = String.class) +public interface InvoiceRepository extends CrudRepository { + List findByAccountOrderByCreatedAtDesc(String account); + List findByMemberIdOrderByCreatedAtDesc(String memberId); + + Invoice findFirstByIdAndAccount(String id, String account); + + @Query("select i from Invoice i order by i.createdAt desc") + List findAllByOrderByCreatedAtDesc(); + + List findByStatusOrderByCreatedAtDesc(String status); + + List findByStatusInAndNextAttemptAtLessThanEqual(List statuses, Instant due); + + List findByStatusAndPaidAtGreaterThanEqual(String status, Instant since); + + long countByAccountAndStatus(String account, String status); + + boolean existsByMembershipIdAndPeriodStart(String membershipId, Instant periodStart); + + /// Whether an account has a charge that may be under way at the processor: + /// one claimed for charging (CHARGING) and not yet settled. + boolean existsByAccountAndStatus(String account, String status); + + boolean existsByAccountAndMethodIdAndStatus(String account, String methodId, String status); + + /// Claims an invoice for one charge attempt. 1 when this caller has it; 0 + /// when another got there first or it is no longer owed. What stops two + /// renewal runs, or a run and a member paying by hand, from both charging. + @Modifying + @Query("update Invoice i set i.status = 'CHARGING', i.attempts = i.attempts + 1" + + " where i.id = :id and (i.status = 'OPEN' or i.status = 'FAILED')") + int claim(@Param("id") String id); + + @Query("select sum(i.amountCents - i.creditCents) from Invoice i where i.account = :account" + + " and i.status = 'PAID'") + Long spent(@Param("account") String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/KudosRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/KudosRepository.java new file mode 100644 index 00000000000..0c9eda16bf1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/KudosRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Kudos; + +import java.util.List; + +@Repository(entity = Kudos.class, key = String.class) +public interface KudosRepository extends CrudRepository { + List findByWorkoutIdIn(List workoutIds); + + boolean existsByFromAccountAndWorkoutId(String fromAccount, String workoutId); + + int deleteByFromAccount(String account); + + int deleteByToAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LiveTicketRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LiveTicketRepository.java new file mode 100644 index 00000000000..06b606dc07e --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LiveTicketRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.LiveTicket; +import java.time.Instant; + +/// The tickets that open a live connection. Only the hash of a ticket is +/// stored, and it is the row's key. +@Repository(entity = LiveTicket.class, key = String.class) +public interface LiveTicketRepository extends CrudRepository { + /// Deletes a ticket and answers how many rows that removed: 1 for the one + /// caller that used it, 0 for anyone presenting it after. A statement and + /// not a read followed by a removal, because that count is the whole + /// point. + int deleteByTicketHash(String ticketHash); + + /// Deletes the tickets that went stale before `now`. + int deleteByExpiresAtLessThan(Instant now); + + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LocationRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LocationRepository.java new file mode 100644 index 00000000000..b80149cfac1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/LocationRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Location; + +import java.util.List; + +@Repository(entity = Location.class, key = String.class) +public interface LocationRepository extends CrudRepository { + @Query("select l from Location l order by l.name asc") + List findAllByOrderByNameAsc(); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MemberRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MemberRepository.java new file mode 100644 index 00000000000..5672ea0ee11 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MemberRepository.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Member; + +import java.util.List; + +@Repository(entity = Member.class, key = String.class) +public interface MemberRepository extends CrudRepository { + List findByAccountOrderByCreatedAtAsc(String account); + + Member findFirstByIdAndAccount(String id, String account); + + Member findFirstByAccountAndRelation(String account, String relation); + + List findByIdIn(List ids); + + long countByAccount(String account); + + /// Members whose name, or whose account's e-mail, contains `text`. + @Query("select m from Member m where m.name like :text or m.account like :text" + + " order by m.name asc") + List search(@Param("text") String text); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipPlanRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipPlanRepository.java new file mode 100644 index 00000000000..80817aaa2b3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipPlanRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.MembershipPlan; + +import java.util.List; + +@Repository(entity = MembershipPlan.class, key = String.class) +public interface MembershipPlanRepository extends CrudRepository { + @Query("select p from MembershipPlan p order by p.priceCents asc") + List findAllByOrderByPriceCentsAsc(); + + List findByActiveTrueOrderByPriceCentsAsc(); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipRepository.java new file mode 100644 index 00000000000..fcadb1284a1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/MembershipRepository.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Membership; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Membership.class, key = String.class) +public interface MembershipRepository extends CrudRepository { + List findByAccountOrderByStartedAtDesc(String account); + + List findByMemberIdAndStateIn(String memberId, List states); + + Membership findFirstByIdAndAccount(String id, String account); + + List findByStateInAndCurrentPeriodEndLessThanEqual(List states, + Instant due); + + List findByStateIn(List states); + + long countByStateIn(List states); + + long countByStateAndUpdatedAtGreaterThanEqual(String state, Instant since); + + long countByStartedAtGreaterThanEqual(Instant since); + + /// Spends one class from a pack. 1 when there was one to spend. + @Modifying + @Query("update Membership m set m.creditsLeft = m.creditsLeft - 1 where m.id = :id" + + " and m.creditsLeft > 0") + int spendCredit(@Param("id") String id); + + @Modifying + @Query("update Membership m set m.creditsLeft = m.creditsLeft + 1 where m.id = :id") + int returnCredit(@Param("id") String id); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ModerationEventRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ModerationEventRepository.java new file mode 100644 index 00000000000..b72eac0e714 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ModerationEventRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.ModerationEvent; + +import java.time.Instant; +import java.util.List; + +/// The record of what the admins did to each account. +@Repository(entity = ModerationEvent.class, key = String.class) +public interface ModerationEventRepository extends CrudRepository { + /// When the newest event of an account was written, or null when it has + /// none. + @Query("select max(e.createdAt) from ModerationEvent e where e.username = :username") + Instant newest(@Param("username") String username); + + /// An account's last hundred events, newest first. + List findTop100ByUsernameOrderByCreatedAtDescIdAsc(String username); + + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/NudgeRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/NudgeRepository.java new file mode 100644 index 00000000000..4998213d3e5 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/NudgeRepository.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Nudge; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Nudge.class, key = String.class) +public interface NudgeRepository extends CrudRepository { + List findByAccountOrderBySentAtDesc(String account); + + Nudge findFirstByIdAndAccount(String id, String account); + + boolean existsByAccountAndSessionIdAndKind(String account, String sessionId, String kind); + + boolean existsByAccountAndKindAndSentAtGreaterThan(String account, String kind, Instant since); + + /// The newest nudge about a class an account was sent and has not booked + /// from yet, to credit a booking to. + Nudge findFirstByAccountAndSessionIdAndBookedAtOrderBySentAtDesc(String account, + String sessionId, Instant notBooked); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PassKeyRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PassKeyRepository.java new file mode 100644 index 00000000000..59d6e5216b3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PassKeyRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PassKey; + +import java.util.List; + +@Repository(entity = PassKey.class, key = String.class) +public interface PassKeyRepository extends CrudRepository { + PassKey findFirstByNfcId(String nfcId); + + List findByMemberIdIn(List memberIds); + + int deleteByMemberIdIn(List memberIds); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentCustomerRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentCustomerRepository.java new file mode 100644 index 00000000000..f03882aca81 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentCustomerRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PaymentCustomer; + +/// The payment processor's names for accounts. +@Repository(entity = PaymentCustomer.class, key = String.class) +public interface PaymentCustomerRepository extends CrudRepository { + /// Writes an account's row without changing it, which holds the row until + /// the transaction ends: whatever else writes it waits its turn. + @Modifying + @Query("update PaymentCustomer c set c.reference = c.reference where c.username = :username") + int hold(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentMethodRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentMethodRepository.java new file mode 100644 index 00000000000..066acbd0145 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentMethodRepository.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PaymentMethod; + +import java.time.Instant; +import java.util.List; + +/// The cards accounts saved. +@Repository(entity = PaymentMethod.class, key = String.class) +public interface PaymentMethodRepository extends CrudRepository { + /// An account's saved cards, oldest first. + List findByUsernameOrderByCreatedAtAscIdAsc(String username); + + long countByUsername(String username); + + /// The card an account pays with by default, or null when that is cash. + PaymentMethod findFirstByUsernameAndPreferredTrue(String username); + + /// One of an account's cards, or null when the id is not a card of theirs. + PaymentMethod findFirstByIdAndUsername(String id, String username); + + /// When the newest of an account's cards was added, or null with none. + @Query("select max(m.createdAt) from PaymentMethod m where m.username = :username") + Instant newestAt(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentSetupRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentSetupRepository.java new file mode 100644 index 00000000000..380b0b9128c --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PaymentSetupRepository.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PaymentSetup; +import java.time.Instant; + +/// The card setups accounts began with the payment processor. +@Repository(entity = PaymentSetup.class, key = String.class) +public interface PaymentSetupRepository extends CrudRepository { + /// A card setup an account began, or null. + PaymentSetup findFirstByIdAndUsername(String id, String username); + + /// Marks a setup finished, if it is not yet. + /// + /// @return 1 for the one caller that finished it + @Modifying + @Query("update PaymentSetup s set s.completed = TRUE where s.id = :id and s.completed = FALSE") + int claim(@Param("id") String id); + + /// How many setups an account began after `since` and has not finished. + long countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(String username, Instant since); + + int deleteByUsername(String username); + + /// Deletes the setups that saved their card. + int deleteByCompletedTrue(); + + /// Deletes the setups begun before `before`, finished or not. + int deleteByCreatedAtLessThan(Instant before); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PhoneCodeRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PhoneCodeRepository.java new file mode 100644 index 00000000000..38e7a1499da --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PhoneCodeRepository.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PhoneCode; + +/// The code an account was texted to prove its phone number. An account has at +/// most one. +@Repository(entity = PhoneCode.class, key = String.class) +public interface PhoneCodeRepository extends CrudRepository { + /// Takes one of the guesses an account's code allows. The database does + /// the sum and applies the limit in the same statement, so of any number + /// of guesses sent at once only as many as are left are let through; + /// nothing is read and written back. + /// + /// The guess is taken of the code whose hash is `codeHash`, and of no + /// other: a new code sent between reading the hash and taking the guess + /// has another hash, and the update changes nothing. + /// + /// The code before it, when one is kept, is guessed at by the same guess, + /// so its count goes up too; its own limit is applied by the caller. + /// + /// Empties the session, so the code is read again after it with the guess + /// counted. + /// + /// @return 1 for a guess taken, 0 when none is left or there is no code + @Modifying(clearAutomatically = true) + @Query("update PhoneCode c set c.attempts = c.attempts + 1, " + + "c.previousAttempts = c.previousAttempts + 1 " + + "where c.username = :username and c.codeHash = :hash and c.attempts < :most") + int takeGuess(@Param("username") String username, @Param("hash") String codeHash, + @Param("most") int most); + + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PreferenceRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PreferenceRepository.java new file mode 100644 index 00000000000..c23ae75c551 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PreferenceRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Preference; + +/// An account's preferences, keyed by its name. An account that never saved any +/// has no row. +@Repository(entity = Preference.class, key = String.class) +public interface PreferenceRepository extends CrudRepository { + int deleteByUsername(String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ProfileRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ProfileRepository.java new file mode 100644 index 00000000000..d6d952a5c24 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ProfileRepository.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Profile; + +import java.time.Instant; +import java.util.List; + +/// Profiles, keyed by the e-mail an account signs in with. +/// +/// A repository holds the queries of one kind of entity and nothing else: no +/// rule about who may do what, and no transaction of its own. The build writes +/// its implementation, and every call runs on the session of the transaction +/// its caller opened, so a service method marked `@Transactional` is what +/// makes several of these calls one unit of work. Called outside one, it is +/// refused. +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same account: the row is written, to the value it has, and a + /// written row is kept by its writer until it commits. For a check and an + /// insert that must not be interleaved with the same pair made for the + /// same account. Call it before anything is read: what is read after it is + /// what the transaction before this one left, while a row read before it + /// was read before the wait. + /// + /// @return 1, or 0 when the account has no profile + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); + + /// Every profile, newest first. + @Query("select p from Profile p order by p.createdAt desc, p.username asc") + List newestFirst(); + + /// The profiles of these accounts; none for a name that has none. + List findByUsernameIn(List usernames); + + /// The display names of some accounts, as `{username, displayName}`. The + /// two fields and not the profiles: a roster shows nothing + /// else of them. + @Query("select p.username, p.displayName from Profile p where p.username in :names") + List displayNames(@Param("names") List usernames); + + long countByFlaggedTrue(); + + /// When each account made since `since` was made. + @Query("select p.createdAt from Profile p where p.createdAt >= :since") + List signUps(@Param("since") Instant since); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PushDeviceRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PushDeviceRepository.java new file mode 100644 index 00000000000..01cf5ff43f1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/PushDeviceRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.PushDevice; + +import java.util.List; + +/// The devices each account is sent notifications on. +@Repository(entity = PushDevice.class, key = String.class) +public interface PushDeviceRepository extends CrudRepository { + List findByUsername(String username); + + int deleteByUsername(String username); + + int deleteByIdAndUsername(String id, String username); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/RoomRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/RoomRepository.java new file mode 100644 index 00000000000..2c418e8119d --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/RoomRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Room; + +import java.util.List; + +@Repository(entity = Room.class, key = String.class) +public interface RoomRepository extends CrudRepository { + @Query("select r from Room r order by r.locationId asc, r.name asc") + List findAllByOrderByLocationIdAscNameAsc(); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ShiftRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ShiftRepository.java new file mode 100644 index 00000000000..5eba506748b --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/ShiftRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Shift; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Shift.class, key = String.class) +public interface ShiftRepository extends CrudRepository { + List findByStaffAndEndsAtGreaterThanOrderByStartsAtAsc(String staff, Instant after); + + List findByLocationIdAndStateAndEndsAtGreaterThanOrderByStartsAtAsc(String locationId, + String state, Instant after); + + List findByLocationIdAndEndsAtGreaterThanOrderByStartsAtAsc(String locationId, + Instant after); + + List findByEndsAtGreaterThanOrderByStartsAtAsc(Instant after); + + /// Hands an offered shift to `taker`. 1 when it did; 0 when somebody took + /// it first or it was withdrawn. + @Modifying + @Query("update Shift s set s.staff = :taker, s.state = 'ASSIGNED' where s.id = :id" + + " and s.state = 'SWAP_REQUESTED' and s.staff <> :taker") + int take(@Param("id") String id, @Param("taker") String taker); + + int deleteByStaff(String staff); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/StaffRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/StaffRepository.java new file mode 100644 index 00000000000..ad1ef2508ef --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/StaffRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Staff; + +import java.util.List; + +@Repository(entity = Staff.class, key = String.class) +public interface StaffRepository extends CrudRepository { + List findByActiveTrueOrderByUsernameAsc(); + + @Query("select s from Staff s order by s.username asc") + List findAllByOrderByUsernameAsc(); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/UsedPassCodeRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/UsedPassCodeRepository.java new file mode 100644 index 00000000000..9a24024df73 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/UsedPassCodeRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.UsedPassCode; + +import java.time.Instant; + +@Repository(entity = UsedPassCode.class, key = String.class) +public interface UsedPassCodeRepository extends CrudRepository { + int deleteByExpiresAtLessThan(Instant now); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WalkInRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WalkInRepository.java new file mode 100644 index 00000000000..b23b5752af4 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WalkInRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.WalkIn; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = WalkIn.class, key = String.class) +public interface WalkInRepository extends CrudRepository { + List findByLocationIdAndAtGreaterThanEqualOrderByAtDesc(String locationId, + Instant since); + + List findByAtGreaterThanEqual(Instant since); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WorkoutRepository.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WorkoutRepository.java new file mode 100644 index 00000000000..23a3f633c85 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/repository/WorkoutRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.pulse.domain.Workout; + +import java.time.Instant; +import java.util.List; + +@Repository(entity = Workout.class, key = String.class) +public interface WorkoutRepository extends CrudRepository { + List findByAccountOrderByStartedAtDesc(String account); + List findBySessionIdIn(List sessionIds); + + List findByAccountAndStartedAtGreaterThanEqual(String account, Instant since); + + List findByAccountInAndSharedTrueAndStartedAtGreaterThanEqualOrderByStartedAtDesc( + List accounts, Instant since); + + boolean existsByAccountAndStartedAt(String account, Instant startedAt); + + int deleteByAccount(String account); +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/ConsoleSmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/ConsoleSmsSender.java new file mode 100644 index 00000000000..b631fd01407 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/ConsoleSmsSender.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +/// Prints the message on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.pulse.Services] hands it +/// out on the `dev`, `test` and `local` profiles alone, and a production server +/// that asks for it is refused with a configuration error. It is not a stand-in +/// for a provider -- nobody receives the text, which is exactly why a +/// development server may then hand the code back to the app. +public class ConsoleSmsSender implements SmsSender { + @Override + public void send(String phone, String text) { + System.out.println("[sms] to " + phone + ": " + text); + } + + @Override + public boolean delivers() { + return false; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MessageBirdSmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MessageBirdSmsSender.java new file mode 100644 index 00000000000..34dcf4efa10 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MessageBirdSmsSender.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Sends through MessageBird's (now Bird's) Messages API: a form POST to +/// `https://rest.messagebird.com/messages` carrying `recipients`, `originator` +/// and `body`, authenticated with an `Authorization: AccessKey ...` header. +/// MessageBird answers 201 when it has accepted the message. +/// +/// Settings: `pulse.sms.messagebird.key` (a live access key) and +/// `pulse.sms.messagebird.originator` (a number, or up to eleven letters and +/// digits). +public class MessageBirdSmsSender implements SmsSender { + private static final String URL = "https://rest.messagebird.com/messages"; + + private final String accessKey; + private final String originator; + + public MessageBirdSmsSender(String accessKey, String originator) { + this.accessKey = accessKey; + this.originator = originator; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: AccessKey " + accessKey); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("recipients", phone, + "originator", originator, "body", text)); + if (sent.getStatus() != 201) { + // The status only: the body can echo the number back. + throw new IOException("MessageBird answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MisconfiguredSmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MisconfiguredSmsSender.java new file mode 100644 index 00000000000..95f9510f22f --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/MisconfiguredSmsSender.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import java.io.IOException; + +/// What the server sends with when the SMS settings are incomplete: every +/// message fails, and says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings that are missing, so it is in front of whoever reads the +/// log at either moment. The failure is an `IOException` like any provider +/// outage, which the phone verification answers with 503: a server whose SMS +/// settings were forgotten verifies nobody, rather than everybody. +public class MisconfiguredSmsSender implements SmsSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredSmsSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(String phone, String text) throws IOException { + System.err.println("[config] a text message was not sent: " + problem); + throw new IOException(problem); + } + + /// True, because it stands in for a provider that would: the demo code a + /// development server hands back is only for the console sender. + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsForm.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsForm.java new file mode 100644 index 00000000000..965a284e5b1 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsForm.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import java.io.IOException; +import java.net.URLEncoder; + +/// Builds an `application/x-www-form-urlencoded` body, which is what all three +/// providers' send calls take. +final class SmsForm { + private SmsForm() { + } + + /// Names and values in turn, each escaped as UTF-8. + static byte[] of(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1], "UTF-8")); + } + return out.toString().getBytes("UTF-8"); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsSender.java new file mode 100644 index 00000000000..f38f20f87ba --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/SmsSender.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import java.io.IOException; + +// tag::pulse-sms-sender[] +/// Sends a text message. The one place the server touches an SMS provider, so +/// changing provider is one class. +public interface SmsSender { + /// - `IOException`: when the message could not be handed to the provider + void send(String phone, String text) throws IOException; + + /// Whether a message really reaches a phone. False for the console sender used in + /// development, which is what lets a development server hand the code back to the + /// app in place of the text message nobody is going to receive. + boolean delivers(); +} +// end::pulse-sms-sender[] diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/TwilioSmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/TwilioSmsSender.java new file mode 100644 index 00000000000..c589fa0b925 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/TwilioSmsSender.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import com.codename1.backend.Base64; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/// Sends through Twilio's Messages API: a form POST to +/// `/2010-04-01/Accounts/{sid}/Messages.json`, authenticated with the account +/// SID and auth token as HTTP Basic credentials. Twilio answers 201 when it +/// has queued the message. +/// +/// Settings: `pulse.sms.twilio.sid`, `pulse.sms.twilio.token` and +/// `pulse.sms.twilio.from` (a Twilio number in E.164 form, or a messaging +/// service's sender). +/// +/// The account's credentials are settings of the server and never leave it: the +/// app asks the server for a code and has no SMS credentials of its own to leak. +public class TwilioSmsSender implements SmsSender { + private final String accountSid; + private final String authToken; + private final String from; + + public TwilioSmsSender(String accountSid, String authToken, String from) { + this.accountSid = accountSid; + this.authToken = authToken; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Basic " + + Base64.encode((accountSid + ":" + authToken).getBytes("UTF-8"))); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", "https://api.twilio.com/2010-04-01/Accounts/" + + URLEncoder.encode(accountSid, "UTF-8") + "/Messages.json", headers, + SmsForm.of("To", phone, "From", from, "Body", text)); + if (!sent.isSuccess()) { + // The status only: the body can echo the number back. + throw new IOException("Twilio answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/VonageSmsSender.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/VonageSmsSender.java new file mode 100644 index 00000000000..2ffc6fbae04 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/sms/VonageSmsSender.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.sms; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +// tag::pulse-sms-provider[] +/// Sends through Vonage's (formerly Nexmo's) SMS API: a form POST to +/// `https://rest.nexmo.com/sms/json` carrying `api_key`, `api_secret`, `to`, +/// `from` and `text`. +/// +/// Vonage answers 200 even for a message it refused; the outcome is the +/// `status` of each entry of `messages`, where `"0"` is success and anything +/// else is an error code. A long text is split into parts and each part has an +/// entry, so every one of them has to be `"0"`. +/// +/// Settings: `pulse.sms.vonage.key`, `pulse.sms.vonage.secret` and +/// `pulse.sms.vonage.from` (a virtual number or an alphanumeric sender id). +/// The number is sent without its leading `+`, as Vonage asks. +public class VonageSmsSender implements SmsSender { + private static final String URL = "https://rest.nexmo.com/sms/json"; + + private final String apiKey; + private final String apiSecret; + private final String from; + + public VonageSmsSender(String apiKey, String apiSecret, String from) { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + String to = phone.startsWith("+") ? phone.substring(1) : phone; + List headers = new ArrayList(); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("api_key", apiKey, + "api_secret", apiSecret, "to", to, "from", from, "text", text)); + if (sent.getStatus() != 200) { + throw new IOException("Vonage answered " + sent.getStatus()); + } + Object messages = Json.parseObject(sent.getBodyAsString()).get("messages"); + if (!(messages instanceof List) || ((List) messages).isEmpty()) { + throw new IOException("Vonage's answer lists no message"); + } + for (Object entry : (List) messages) { + Object status = entry instanceof Map ? ((Map) entry).get("status") : null; + if (!"0".equals(status)) { + // The code only; `error-text` can echo the number back. + throw new IOException("Vonage refused the message with status " + status); + } + } + } + + @Override + public boolean delivers() { + return true; + } +} +// end::pulse-sms-provider[] diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/Social.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/Social.java new file mode 100644 index 00000000000..0a6e15430f3 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/Social.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.FeedItemDto; +import com.codenameone.examples.pulse.api.FriendDto; +import com.codenameone.examples.pulse.api.InviteDto; +import com.codenameone.examples.pulse.api.NudgeDto; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Friend; +import com.codenameone.examples.pulse.domain.Kudos; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Workout; +import com.codenameone.examples.pulse.engagement.Engagement; +import com.codenameone.examples.pulse.push.Notifier; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.FriendRepository; +import com.codenameone.examples.pulse.repository.KudosRepository; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.WorkoutRepository; +import com.codenameone.examples.pulse.studio.Studio; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Friends at the studio: who they are, what they did, kudos for it, and +/// asking one along to a class. +/// +/// A friendship is two rows, one per direction, each saying "I want to be +/// friends with". One row is a request; both are a friendship. Nothing about +/// a member -- their visits, their workouts -- is shown to anyone they have +/// not accepted, and a workout only when they marked it shared. +@Component +public class Social { + /// [FriendDto#state] values. + public static final String FRIENDS = "FRIENDS"; + public static final String REQUESTED = "REQUESTED"; + public static final String INCOMING = "INCOMING"; + static final String ACCEPTED = "ACCEPTED"; + static final int MOST_FRIENDS = 200; + /// How far back the feed reaches. + static final long FEED_MILLIS = 14L * 86400000L; + + private final FriendRepository friends; + private final ProfileRepository profiles; + private final WorkoutRepository workouts; + private final KudosRepository kudos; + private final CheckInRepository checkIns; + private final BookingRepository bookings; + private final ClassTypeRepository types; + private final NudgeRepository nudges; + private final Engagement engagement; + private final Studio studio; + private final Notifier notifier; + + public Social(FriendRepository friends, ProfileRepository profiles, WorkoutRepository workouts, + KudosRepository kudos, CheckInRepository checkIns, BookingRepository bookings, + ClassTypeRepository types, NudgeRepository nudges, Engagement engagement, + Studio studio, Notifier notifier) { + this.friends = friends; + this.profiles = profiles; + this.workouts = workouts; + this.kudos = kudos; + this.checkIns = checkIns; + this.bookings = bookings; + this.types = types; + this.nudges = nudges; + this.engagement = engagement; + this.studio = studio; + this.notifier = notifier; + } + + /// Friends first, then requests either way, each by name. + @Transactional(readOnly = true) + public List friends(String account) { + Map states = states(account); + List out = new ArrayList(); + String[] order = {FRIENDS, INCOMING, REQUESTED}; + for (int pass = 0; pass < order.length; pass++) { + for (Map.Entry entry : states.entrySet()) { + if (order[pass].equals(entry.getValue())) { + out.add(toDto(entry.getKey(), entry.getValue())); + } + } + } + return out; + } + + /// Each other account the caller has a row with, or that has one with + /// the caller, and what that makes them. + private Map states(String account) { + Map out = new LinkedHashMap(); + List mine = friends.findByAccount(account); + for (int iter = 0; iter < mine.size(); iter++) { + out.put(mine.get(iter).getFriend(), REQUESTED); + } + List theirs = friends.findByFriend(account); + for (int iter = 0; iter < theirs.size(); iter++) { + String other = theirs.get(iter).getAccount(); + out.put(other, out.containsKey(other) ? FRIENDS : INCOMING); + } + return out; + } + + private String state(String account, String other) { + String state = states(account).get(other); + return state == null ? "" : state; + } + + /// Asks someone to be friends, by the e-mail they sign in with. Asking + /// someone who already asked the caller makes them friends. Someone with + /// no account is answered the same as someone who has one: the caller + /// learns nothing about who is a member. + @Transactional(rollbackFor = IOException.class) + public FriendDto request(String account, String email) throws IOException { + String other = Accounts.email(email); + Profile them = profiles.findById(other); + if (other.equals(account)) { + throw new ResponseStatusException(400, "That is you"); + } + if (friends.findByAccount(account).size() >= MOST_FRIENDS) { + throw new ResponseStatusException(409, "That is a lot of friends already"); + } + if (them == null || them.isFlagged()) { + return new FriendDto(other, other, REQUESTED, 0, 0); + } + link(account, other); + return toDto(other, state(account, other)); + } + + /// Accepts a request someone sent. + @Transactional(rollbackFor = IOException.class) + public FriendDto accept(String account, String other) throws IOException { + if (!INCOMING.equals(state(account, other))) { + throw new ResponseStatusException(404, "No such request"); + } + link(account, other); + return toDto(other, FRIENDS); + } + + private void link(String account, String other) throws IOException { + List mine = friends.findByAccount(account); + for (int iter = 0; iter < mine.size(); iter++) { + if (mine.get(iter).getFriend().equals(other)) { + return; + } + } + Friend row = new Friend(); + row.setId(Ids.next()); + row.setAccount(account); + row.setFriend(other); + row.setState(ACCEPTED); + row.setCreatedAt(Instant.now()); + friends.insert(row); + } + + /// Ends a friendship, declines a request or withdraws one: both + /// directions go. + @Transactional(rollbackFor = IOException.class) + public FriendDto remove(String account, String other) { + boolean any = false; + List mine = friends.findByAccount(account); + for (int iter = 0; iter < mine.size(); iter++) { + if (mine.get(iter).getFriend().equals(other)) { + friends.deleteById(mine.get(iter).getId()); + any = true; + } + } + List theirs = friends.findByFriend(account); + for (int iter = 0; iter < theirs.size(); iter++) { + if (theirs.get(iter).getAccount().equals(other)) { + friends.deleteById(theirs.get(iter).getId()); + any = true; + } + } + if (!any) { + throw new ResponseStatusException(404, "No such friend"); + } + return new FriendDto(other, name(other), "", 0, 0); + } + + private FriendDto toDto(String other, String state) { + if (!FRIENDS.equals(state)) { + return new FriendDto(other, name(other), state, 0, 0); + } + Instant last = checkIns.lastVisit(other); + return new FriendDto(other, name(other), state, last == null ? 0 : last.toEpochMilli(), + engagement.stats(other).weekStreak()); + } + + private String name(String account) { + Profile who = profiles.findById(account); + return who == null ? "" : who.getDisplayName(); + } + + /// The caller's and their friends' shared workouts of the last two weeks, + /// newest first, with their kudos. + @Transactional(readOnly = true) + public List feed(String account) { + List circle = circle(account); + circle.add(account); + List rows = workouts.findByAccountInAndSharedTrueAndStartedAtGreaterThanEqualOrderByStartedAtDesc( + circle, Instant.ofEpochMilli(System.currentTimeMillis() - FEED_MILLIS)); + List ids = new ArrayList(); + for (int iter = 0; iter < rows.size() && iter < 100; iter++) { + ids.add(rows.get(iter).getId()); + } + Map counts = new HashMap(); + List given = ids.isEmpty() ? new ArrayList() : kudos.findByWorkoutIdIn(ids); + for (int iter = 0; iter < given.size(); iter++) { + Kudos one = given.get(iter); + int[] count = counts.get(one.getWorkoutId()); + if (count == null) { + count = new int[2]; + counts.put(one.getWorkoutId(), count); + } + count[0]++; + if (one.getFromAccount().equals(account)) { + count[1] = 1; + } + } + Map names = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < ids.size(); iter++) { + Workout row = rows.get(iter); + String who = names.get(row.getAccount()); + if (who == null) { + who = name(row.getAccount()); + names.put(row.getAccount(), who); + } + int[] count = counts.get(row.getId()); + out.add(new FeedItemDto(row.getId(), "WORKOUT", row.getAccount(), who, + row.getActivity(), row.getStartedAt().toEpochMilli(), row.getMinutes(), + row.getAvgHr(), row.getCalories(), count == null ? 0 : count[0], + count != null && count[1] == 1, row.getSessionId())); + } + return out; + } + + private List circle(String account) { + List out = new ArrayList(); + for (Map.Entry entry : states(account).entrySet()) { + if (FRIENDS.equals(entry.getValue())) { + out.add(entry.getKey()); + } + } + return out; + } + + /// Kudos for a friend's shared workout. Given twice, counted once. + @Transactional(rollbackFor = IOException.class) + public FeedItemDto kudos(String account, String workoutId) throws IOException { + Workout row = workoutId == null || workoutId.length() > 32 ? null + : workouts.findById(workoutId); + if (row == null || !row.isShared() || !FRIENDS.equals(state(account, row.getAccount()))) { + throw new ResponseStatusException(404, "No such workout"); + } + if (!kudos.existsByFromAccountAndWorkoutId(account, row.getId())) { + Kudos one = new Kudos(); + one.setId(Ids.next()); + one.setFromAccount(account); + one.setToAccount(row.getAccount()); + one.setWorkoutId(row.getId()); + one.setCreatedAt(Instant.now()); + kudos.insert(one); + } + List ids = new ArrayList(); + ids.add(row.getId()); + int count = kudos.findByWorkoutIdIn(ids).size(); + return new FeedItemDto(row.getId(), "WORKOUT", row.getAccount(), name(row.getAccount()), + row.getActivity(), row.getStartedAt().toEpochMilli(), row.getMinutes(), + row.getAvgHr(), row.getCalories(), count, true, row.getSessionId()); + } + + /// Asks a friend along to a class. Once per friend per class; a friend + /// already in it is not asked. + @Transactional(rollbackFor = IOException.class) + public NudgeDto invite(String account, InviteDto request) throws IOException { + if (request == null || request.friend() == null) { + throw new ResponseStatusException(400, "Choose a friend"); + } + if (!FRIENDS.equals(state(account, request.friend()))) { + throw new ResponseStatusException(404, "No such friend"); + } + ClassSession session = studio.session(request.sessionId()); + if (!Studio.SCHEDULED.equals(session.getState()) + || !session.getStartsAt().isAfter(Instant.now())) { + throw new ResponseStatusException(409, "That class has started or is off"); + } + List in = bookings.findBySessionIdOrderByCreatedAtAsc(session.getId()); + for (int iter = 0; iter < in.size(); iter++) { + if (in.get(iter).getAccount().equals(request.friend())) { + throw new ResponseStatusException(409, name(request.friend()) + + " is in that class already"); + } + } + if (nudges.existsByAccountAndSessionIdAndKind(request.friend(), session.getId(), + Notifier.INVITE)) { + throw new ResponseStatusException(409, name(request.friend()) + + " has been asked to that class"); + } + ClassType type = types.findById(session.getClassTypeId()); + return Engagement.toDto(notifier.tell(request.friend(), Notifier.INVITE, "", + session.getId(), name(account) + " asked you along", + "Join " + name(account) + " at " + (type == null ? "a class" : type.getName()) + + ".", Instant.now())); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/SocialEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/SocialEndpoint.java new file mode 100644 index 00000000000..55718a47afe --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/social/SocialEndpoint.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.social; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.FeedItemDto; +import com.codenameone.examples.pulse.api.FriendDto; +import com.codenameone.examples.pulse.api.FriendRequestDto; +import com.codenameone.examples.pulse.api.InviteDto; +import com.codenameone.examples.pulse.api.NudgeDto; +import com.codenameone.examples.pulse.api.SocialApiServer; + +import java.util.List; + +/// The server's half of `SocialApi`. +@Component +public class SocialEndpoint implements SocialApiServer { + private final Social social; + + public SocialEndpoint(Social social) { + this.social = social; + } + + @Override + public List friends() throws Exception { + return social.friends(Caller.name()); + } + + @Override + public FriendDto request(FriendRequestDto body) throws Exception { + return social.request(Caller.name(), body == null ? null : body.email()); + } + + @Override + public FriendDto accept(String account) throws Exception { + return social.accept(Caller.name(), account); + } + + @Override + public FriendDto remove(String account) throws Exception { + return social.remove(Caller.name(), account); + } + + @Override + public List feed() throws Exception { + return social.feed(Caller.name()); + } + + @Override + public FeedItemDto kudos(String id) throws Exception { + return social.kudos(Caller.name(), id); + } + + @Override + public NudgeDto invite(InviteDto body) throws Exception { + return social.invite(Caller.name(), body); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Bookings.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Bookings.java new file mode 100644 index 00000000000..87dd8786d81 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Bookings.java @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.BookRequestDto; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Location; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.engagement.Nudger; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.push.Notifier; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.LocationRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; + +import java.io.IOException; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Places in classes: booking one, the waitlist behind a full class, and +/// giving a place back. +/// +/// The one rule that must never break is that a class is not booked past its +/// capacity, however many people tap Book at the same moment. It is kept by +/// the database, not by this class: the count of places taken is a column of +/// the class, and the only statement that raises it does so on condition +/// that it is still below capacity ([ClassSessionRepository#takePlace]). A +/// booking whose statement changed no row joins the waitlist instead. Two +/// requests for the last place both run that statement; the database makes +/// them take turns on the row, and the second finds it full. +/// +/// Everything else is checked before that: that the class is still to come, +/// that the member is the right age for it, has signed the waiver, and holds +/// a membership that covers it. +@Component +public class Bookings { + public static final String BOOKED = "BOOKED"; + public static final String WAITLISTED = "WAITLISTED"; + public static final String CHECKED_IN = "CHECKED_IN"; + public static final String CANCELLED = "CANCELLED"; + /// Given back too close to the start for the place to be useful to + /// anyone else. A class from a pack is spent. + public static final String LATE_CANCEL = "LATE_CANCEL"; + /// Booked, not cancelled, never came. + public static final String NO_SHOW = "NO_SHOW"; + /// How long before a class a place can be given back for free. + public static final long LATE_MILLIS = 12L * 3600000L; + /// How long after a class ends its bookings are settled: anyone still + /// only booked becomes a no-show, and the class is done. + static final long SETTLE_MILLIS = 30L * 60000L; + /// A waiting member is not moved into a place this close to the start: + /// they could not get there. The place stays free for the door. + static final long PROMOTE_UNTIL_MILLIS = 30L * 60000L; + + private final ClassSessionRepository sessions; + private final BookingRepository bookings; + private final ClassTypeRepository types; + private final LocationRepository locations; + private final MemberRepository members; + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final Household household; + private final Studio studio; + private final Accounts accounts; + private final LiveHub live; + private final Nudger nudger; + private final Notifier notifier; + + public Bookings(ClassSessionRepository sessions, BookingRepository bookings, + ClassTypeRepository types, LocationRepository locations, MemberRepository members, + MembershipRepository memberships, MembershipPlanRepository plans, + Household household, Studio studio, Accounts accounts, LiveHub live, Nudger nudger, + Notifier notifier) { + this.sessions = sessions; + this.bookings = bookings; + this.types = types; + this.locations = locations; + this.members = members; + this.memberships = memberships; + this.plans = plans; + this.household = household; + this.studio = studio; + this.accounts = accounts; + this.live = live; + this.nudger = nudger; + this.notifier = notifier; + } + + /// Books a member of the caller's household into a class, or puts them on + /// its waitlist when it is full. Asking again for a member already booked + /// or waiting answers the booking they have. + public BookingDto book(String account, BookRequestDto request) throws IOException { + Booking made = place(account, request); + ClassSession session = reread(made.getSessionId()); + if (session != null) { + live.sessionChanged(session.getId(), session.getBooked(), session.getWaitlisted(), + session.getState()); + } + return settled(account, made); + } + + @Transactional(rollbackFor = IOException.class) + Booking place(String account, BookRequestDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Choose a class"); + } + // Held first: a closing account and a booking for it take turns. + accounts.hold(account); + Member member = request.memberId() == null || request.memberId().length() == 0 + ? members.findFirstByAccountAndRelation(account, Accounts.SELF) + : household.mine(account, request.memberId()); + if (member == null) { + throw new ResponseStatusException(404, "No such member"); + } + ClassSession session = studio.session(request.sessionId()); + Booking existing = bookings.findFirstBySessionIdAndMemberId(session.getId(), + member.getId()); + if (existing != null && (BOOKED.equals(existing.getState()) + || WAITLISTED.equals(existing.getState()) + || CHECKED_IN.equals(existing.getState()))) { + return existing; + } + Instant now = Instant.now(); + Membership membership = eligible(member, session, now); + Booking row = existing == null ? new Booking() : existing; + if (existing == null) { + row.setId(Ids.next()); + row.setSessionId(session.getId()); + row.setMemberId(member.getId()); + row.setAccount(account); + row.setCreatedAt(now); + } + row.setUpdatedAt(now); + row.setCheckedInAt(Instant.EPOCH); + row.setInvitedBy(cleanInviter(request.invitedBy(), account)); + if (sessions.takePlace(session.getId()) == 1) { + spend(membership); + row.setState(BOOKED); + row.setQueuePosition(0L); + nudger.booked(account, session.getId(), now); + } else { + // Full. The queue's row is held so that two people joining it at + // once are given different places in it. + sessions.hold(session.getId()); + Long last = bookings.lastPosition(session.getId()); + sessions.moveQueue(session.getId(), 1); + row.setState(WAITLISTED); + row.setQueuePosition(last == null ? 1L : last.longValue() + 1L); + } + if (existing == null) { + bookings.insert(row); + } + return row; + } + + /// Gives a place back, or leaves the waitlist. Inside + /// [#LATE_MILLIS] of the start a place is a late cancellation: the + /// member loses the class from a pack, as they would by not coming. + public BookingDto cancel(String account, String bookingId) throws IOException { + Booking row = release(account, bookingId); + promote(row.getSessionId()); + ClassSession session = reread(row.getSessionId()); + if (session != null) { + live.sessionChanged(session.getId(), session.getBooked(), session.getWaitlisted(), + session.getState()); + } + return settled(account, row); + } + + @Transactional(rollbackFor = IOException.class) + Booking release(String account, String bookingId) throws IOException { + Booking row = bookingId == null || bookingId.length() > 32 ? null + : bookings.findFirstByIdAndAccount(bookingId, account); + if (row == null) { + throw new ResponseStatusException(404, "No such booking"); + } + ClassSession session = studio.session(row.getSessionId()); + Instant now = Instant.now(); + if (BOOKED.equals(row.getState())) { + if (!session.getStartsAt().isAfter(now)) { + throw new ResponseStatusException(409, "That class has started"); + } + boolean late = session.getStartsAt().toEpochMilli() - now.toEpochMilli() + < LATE_MILLIS; + sessions.givePlace(session.getId()); + if (!late) { + giveBack(row.getMemberId()); + } + row.setState(late ? LATE_CANCEL : CANCELLED); + } else if (WAITLISTED.equals(row.getState())) { + sessions.moveQueue(session.getId(), -1); + row.setState(CANCELLED); + } else { + return row; + } + row.setUpdatedAt(now); + return row; + } + + /// Moves the head of a class's waitlist into the places that are free, + /// and tells each account moved. Safe to call at any time and from + /// anywhere: with no place or nobody waiting it does nothing. + public void promote(String sessionId) throws IOException { + List moved = fill(sessionId); + for (int iter = 0; iter < moved.size(); iter++) { + live.bookingChanged(moved.get(iter).getAccount(), moved.get(iter).getId(), BOOKED); + } + if (!moved.isEmpty()) { + ClassSession session = reread(sessionId); + if (session != null) { + live.sessionChanged(session.getId(), session.getBooked(), + session.getWaitlisted(), session.getState()); + } + } + } + + @Transactional(rollbackFor = IOException.class) + List fill(String sessionId) throws IOException { + List moved = new ArrayList(); + ClassSession session = sessions.findById(sessionId); + Instant now = Instant.now(); + if (session == null || !Studio.SCHEDULED.equals(session.getState()) + || session.getStartsAt().toEpochMilli() - now.toEpochMilli() + < PROMOTE_UNTIL_MILLIS) { + return moved; + } + // One promotion at a time per class, so that the queue is walked by + // one request and nobody is moved twice. + sessions.hold(sessionId); + List waiting = bookings.findBySessionIdAndStateOrderByQueuePositionAsc(sessionId, + WAITLISTED); + for (int iter = 0; iter < waiting.size(); iter++) { + Booking next = waiting.get(iter); + Member member = members.findById(next.getMemberId()); + Membership membership; + try { + membership = member == null ? null : eligible(member, session, now); + } catch (ResponseStatusException noLonger) { + // Their membership lapsed or their pack ran out while they + // waited. They keep their place in the queue, and the next + // in line is offered this one. + continue; + } + if (membership == null || sessions.takePlace(sessionId) != 1) { + break; + } + spend(membership); + sessions.moveQueue(sessionId, -1); + next.setState(BOOKED); + next.setQueuePosition(0L); + next.setUpdatedAt(now); + moved.add(next); + ClassType type = types.findById(session.getClassTypeId()); + notifier.tell(next.getAccount(), Notifier.PROMOTED, "", sessionId, "You're in", + member.getName() + " has a place in " + (type == null ? "the class" + : type.getName()) + " now. Can't make it? Cancel to pass it on.", + now); + } + return moved; + } + + /// The bookings of the caller's household still to come, and those on + /// waitlists, soonest first. + @Transactional(readOnly = true) + public List upcoming(String account) throws IOException { + return list(account, Arrays.asList(BOOKED, WAITLISTED), true); + } + + /// Classes the household came to, or missed, most recent first. + @Transactional(readOnly = true) + public List history(String account) throws IOException { + return list(account, Arrays.asList(CHECKED_IN, NO_SHOW, LATE_CANCEL), false); + } + + private List list(String account, List states, boolean ahead) + throws IOException { + List rows = bookings.findByAccountAndStateIn(account, states); + List ids = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ids.add(rows.get(iter).getSessionId()); + } + Map byId = new HashMap(); + if (!ids.isEmpty()) { + List described = studio.describe(account, sessions.findByIdIn(ids)); + for (int iter = 0; iter < described.size(); iter++) { + byId.put(described.get(iter).id(), described.get(iter)); + } + } + Map names = names(account); + long now = System.currentTimeMillis(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Booking row = rows.get(iter); + SessionDto session = byId.get(row.getSessionId()); + if (session == null || ahead != (session.endsAt() > now)) { + continue; + } + out.add(dto(row, names, session)); + } + out.sort((a, b) -> ahead ? Long.compare(a.session().startsAt(), b.session().startsAt()) + : Long.compare(b.session().startsAt(), a.session().startsAt())); + return out; + } + + /// A class as it stands after a change committed, for telling the live + /// connections about it. + @Transactional(readOnly = true) + ClassSession reread(String id) { + return sessions.findById(id); + } + + @Transactional(readOnly = true) + BookingDto settled(String account, Booking row) throws IOException { + return describe(account, row); + } + + private BookingDto describe(String account, Booking row) throws IOException { + return dto(row, names(account), studio.get(account, row.getSessionId())); + } + + private Map names(String account) { + Map names = new HashMap(); + List household = members.findByAccountOrderByCreatedAtAsc(account); + for (int iter = 0; iter < household.size(); iter++) { + names.put(household.get(iter).getId(), household.get(iter).getName()); + } + return names; + } + + private static BookingDto dto(Booking row, Map names, SessionDto session) { + String name = names.get(row.getMemberId()); + return new BookingDto(row.getId(), row.getSessionId(), row.getMemberId(), + name == null ? "" : name, row.getState(), (int) row.getQueuePosition(), + row.getCreatedAt().toEpochMilli(), row.getCheckedInAt().toEpochMilli(), session); + } + + /// The membership that lets `member` into `session`, or a 4xx saying why + /// none does. Every check that does not depend on the class's places. + Membership eligible(Member member, ClassSession session, Instant now) { + if (!Studio.SCHEDULED.equals(session.getState())) { + throw new ResponseStatusException(409, "That class is not running"); + } + if (!session.getStartsAt().isAfter(now)) { + throw new ResponseStatusException(409, "That class has started"); + } + ClassType type = types.findById(session.getClassTypeId()); + LocalDate day = LocalDate.ofEpochDay(session.getStartsAt().toEpochMilli() / 86400000L); + int age = Accounts.age(member.getBirthDate(), day); + if (type != null && !inBand(age, type.getMinAge(), type.getMaxAge())) { + throw new ResponseStatusException(403, member.getName() + " is " + age + "; " + + type.getName() + " is for " + band(type.getMinAge(), type.getMaxAge())); + } + if (!Household.signed(member)) { + throw new ResponseStatusException(403, "Sign the waiver for " + member.getName() + + " first"); + } + Membership membership = household.current(member.getId()); + if (membership == null) { + throw new ResponseStatusException(402, member.getName() + + " needs a membership or a class pack"); + } + if ("FROZEN".equals(membership.getState()) + && !membership.getFrozenUntil().isBefore(session.getStartsAt())) { + throw new ResponseStatusException(402, member.getName() + + "'s membership is frozen for that date"); + } + if ("PAST_DUE".equals(membership.getState())) { + throw new ResponseStatusException(402, "Settle the unpaid invoice to book"); + } + MembershipPlan plan = plans.findById(membership.getPlanId()); + if (plan != null) { + if (!inBand(age, plan.getMinAge(), plan.getMaxAge())) { + throw new ResponseStatusException(403, plan.getName() + " does not cover " + + member.getName() + "'s age"); + } + if (plan.isOffPeakOnly() && peak(session)) { + throw new ResponseStatusException(403, plan.getName() + + " covers off-peak classes only"); + } + if (Household.counted(plan) && membership.getCreditsLeft() <= 0) { + throw new ResponseStatusException(402, "The class pack is used up"); + } + } + return membership; + } + + /// Takes a class from a pack. Anything else has nothing to spend. A pack + /// emptied by another booking since [#eligible] read it refuses this one, + /// and the transaction -- the place included -- rolls back. + private void spend(Membership membership) { + MembershipPlan plan = plans.findById(membership.getPlanId()); + if (plan != null && Household.counted(plan) + && memberships.spendCredit(membership.getId()) != 1) { + throw new ResponseStatusException(402, "The class pack is used up"); + } + } + + private void giveBack(String memberId) { + Membership membership = household.current(memberId); + if (membership == null) { + return; + } + MembershipPlan plan = plans.findById(membership.getPlanId()); + if (plan != null && Household.counted(plan)) { + memberships.returnCredit(membership.getId()); + } + } + + /// Whether a class starts in the busy hours: a weekday from six to nine + /// in the morning or five to eight in the evening, at the location. + boolean peak(ClassSession session) { + Location location = locations.findById(session.getLocationId()); + ZoneId zone; + try { + zone = ZoneId.of(location == null || location.getTimeZone().length() == 0 ? "UTC" + : location.getTimeZone()); + } catch (RuntimeException unknown) { + zone = ZoneId.of("UTC"); + } + ZonedDateTime at = ZonedDateTime.ofInstant(session.getStartsAt(), zone); + DayOfWeek day = at.getDayOfWeek(); + if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) { + return false; + } + int hour = at.getHour(); + return hour >= 6 && hour < 9 || hour >= 17 && hour < 20; + } + + static boolean inBand(int age, int min, int max) { + return age >= min && (max <= 0 || age <= max); + } + + static String band(int min, int max) { + if (max <= 0) { + return "ages " + min + " and up"; + } + return "ages " + min + " to " + max; + } + + /// An inviter is kept only when it is another account: it is what a + /// friend's invitation is credited by. + private String cleanInviter(String invitedBy, String account) { + if (invitedBy == null || invitedBy.length() == 0 || invitedBy.length() > 190 + || invitedBy.equals(account) || !accounts.exists(invitedBy)) { + return ""; + } + return invitedBy; + } + + /// Every minute: classes that have a free place and a queue get it + /// filled -- a place freed by a closed account or a cancelled membership + /// is noticed here -- and classes that ended are settled. + @Scheduled(fixedDelay = 60000, initialDelay = 30000, lock = "pulse-bookings") + // The two lookups below read through the injected session, which needs a + // transaction open; promote and settle join it. One sweep is one + // transaction: a failure rolls the whole sweep back and the next run, a + // minute later, does it again, which every step here is safe to repeat. + @Transactional(rollbackFor = IOException.class) + public void sweep() throws IOException { + Instant now = Instant.now(); + List soon = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now, now.plusMillis(14L * 86400000L)); + for (int iter = 0; iter < soon.size(); iter++) { + ClassSession session = soon.get(iter); + if (session.getWaitlisted() > 0 && session.getBooked() < session.getCapacity()) { + promote(session.getId()); + } + } + List ended = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.minusMillis(7L * 86400000L), now); + for (int iter = 0; iter < ended.size(); iter++) { + ClassSession session = ended.get(iter); + if (Studio.SCHEDULED.equals(session.getState()) + && session.getEndsAt().toEpochMilli() + SETTLE_MILLIS < now.toEpochMilli()) { + settle(session.getId()); + } + } + } + + /// Ends a class: who is still only booked did not come, and who is still + /// waiting never got in. + @Transactional(rollbackFor = IOException.class) + void settle(String sessionId) { + ClassSession session = sessions.findById(sessionId); + if (session == null || !Studio.SCHEDULED.equals(session.getState())) { + return; + } + Instant now = Instant.now(); + List rows = bookings.findBySessionIdOrderByCreatedAtAsc(sessionId); + for (int iter = 0; iter < rows.size(); iter++) { + Booking row = rows.get(iter); + if (BOOKED.equals(row.getState())) { + row.setState(NO_SHOW); + row.setUpdatedAt(now); + } else if (WAITLISTED.equals(row.getState())) { + row.setState(CANCELLED); + row.setUpdatedAt(now); + } + } + session.setState(Studio.DONE); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoHeartRates.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoHeartRates.java new file mode 100644 index 00000000000..0e8f03dea48 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoHeartRates.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.live.LiveHub; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.trainer.HeartRates; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/// Simulated straps for the dev profile: every five seconds, each member +/// checked in to a class that is running now gets a heart rate, so the +/// trainer's live screen moves without anyone wearing anything. Readings +/// drift smoothly around a target that differs per member, which spreads the +/// room across zones 2 to 5. Nothing happens outside the dev profile. +@Component +public class DemoHeartRates { + private final Config config; + private final ClassSessionRepository sessions; + private final BookingRepository bookings; + private final MemberRepository members; + private final HeartRates rates; + private final LiveHub live; + + public DemoHeartRates(Config config, ClassSessionRepository sessions, + BookingRepository bookings, MemberRepository members, HeartRates rates, + LiveHub live) { + this.config = config; + this.sessions = sessions; + this.bookings = bookings; + this.members = members; + this.rates = rates; + this.live = live; + } + + @Scheduled(fixedDelay = 5000, initialDelay = 5000) + @Transactional(readOnly = true) + public void beat() { + if (!config.isDevelopmentProfile()) { + return; + } + long now = System.currentTimeMillis(); + Instant at = Instant.ofEpochMilli(now); + // A class runs at most a couple of hours, so one that started in the + // last three and has not ended is the whole set. + List started = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + at.minusMillis(3L * 3600000L), at); + LocalDate today = LocalDate.now(); + for (int iter = 0; iter < started.size(); iter++) { + ClassSession session = started.get(iter); + if (!session.getEndsAt().isAfter(at) || Studio.CANCELLED.equals(session.getState())) { + continue; + } + List roster = bookings.findBySessionIdAndStateOrderByQueuePositionAsc( + session.getId(), Bookings.CHECKED_IN); + for (int each = 0; each < roster.size(); each++) { + Member member = members.findById(roster.get(each).getMemberId()); + if (member == null) { + continue; + } + int age = Accounts.age(member.getBirthDate(), today); + int bpm = bpm(member.getId(), age, now); + int zone = HeartRates.zone(bpm, age); + rates.put(session.getId(), member.getId(), bpm, zone, now); + live.heartRate(session.getTrainer(), session.getId(), member.getId(), bpm, zone); + } + } + } + + /// A heart rate that wanders slowly around a share of the member's + /// maximum: the member's id picks the share (60% to 92%) and the phase of + /// two slow waves, so neighbours do not move in step. + static int bpm(String memberId, int age, long now) { + int hash = memberId.hashCode() & 0x7fffffff; + int max = Math.max(120, 220 - age); + double share = 0.60 + (hash % 5) * 0.08; + double phase = (hash % 997) / 997.0 * 2 * Math.PI; + double seconds = now / 1000.0; + double wave = Math.sin(seconds / 40.0 + phase) * 0.05 + + Math.sin(seconds / 11.0 + phase * 3) * 0.02; + return (int) Math.round(max * Math.min(0.97, share + wave)); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoStudio.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoStudio.java new file mode 100644 index 00000000000..3a5d5b52ab0 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/DemoStudio.java @@ -0,0 +1,1204 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.DemoAccounts; +import com.codenameone.examples.pulse.trainer.Trainers; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.Campaign; +import com.codenameone.examples.pulse.domain.CheckIn; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Friend; +import com.codenameone.examples.pulse.domain.Invoice; +import com.codenameone.examples.pulse.domain.Location; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.WalkIn; +import com.codenameone.examples.pulse.domain.Kudos; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Workout; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.Room; +import com.codenameone.examples.pulse.domain.Shift; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CampaignRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.FriendRepository; +import com.codenameone.examples.pulse.repository.KudosRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.LocationRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; +import com.codenameone.examples.pulse.repository.ShiftRepository; +import com.codenameone.examples.pulse.repository.WalkInRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; +import com.codenameone.examples.pulse.repository.WorkoutRepository; + +import java.io.IOException; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/// The studio a development server starts with: two locations, a timetable +/// of adult and children's classes from three weeks back to three weeks +/// ahead, the plans on sale, and the demo accounts' memberships, children +/// and visits. +/// +/// Written once, the first time a server starts against an empty database; +/// the marker is the first location. The history is real rows of the same +/// tables the app writes -- bookings, check-ins, paid invoices -- so every +/// screen that reads them, the owner's dashboard included, has something to +/// show without a special case. +@Component +public class DemoStudio { + public static final String DOWNTOWN = "loc-downtown"; + public static final String RIVERSIDE = "loc-riverside"; + /// The studio's time zone when `pulse.demo.zone` names none. The timetable + /// is laid out in studio hours, so a screenshot run sets the zone to one + /// where it is morning, and the device to the same zone, rather than + /// showing an evening of late classes. + public static final String DEFAULT_ZONE = "America/New_York"; + /// The plan ids the tests name. + public static final String UNLIMITED = "plan-unlimited"; + public static final String OFF_PEAK = "plan-offpeak"; + public static final String PACK = "plan-pack10"; + public static final String KIDS = "plan-kids"; + public static final String DROP_IN = "plan-dropin"; + public static final String ANNUAL = "plan-annual"; + /// The class type ids the tests name. + public static final String HIIT = "type-hiit"; + public static final String LITTLE_MOVERS = "type-littlemovers"; + public static final String TEEN = "type-teen"; + /// The member ids of the demo household's children. + public static final String KAI = "member-kai"; + public static final String MIA = "member-mia"; + static final int WEEKS_BACK = 3; + static final int WEEKS_AHEAD = 3; + + private final DemoAccounts accounts; + private final Config config; + private final LocationRepository locations; + private final RoomRepository rooms; + private final ClassTypeRepository types; + private final ClassSessionRepository sessions; + private final StaffRepository staff; + private final MembershipPlanRepository plans; + private final MembershipRepository memberships; + private final MemberRepository members; + private final BookingRepository bookings; + private final CheckInRepository checkIns; + private final InvoiceRepository invoices; + private final WorkoutRepository workouts; + private final FriendRepository friends; + private final KudosRepository kudos; + private final ShiftRepository shifts; + private final CampaignRepository campaigns; + /// A fixed sequence, so that every demo database has the same history. + private long seed = 20261030L; + private final String zone; + private final WalkInRepository walkIns; + private final ProfileRepository profiles; + + public DemoStudio(DemoAccounts accounts, Config config, LocationRepository locations, + RoomRepository rooms, ClassTypeRepository types, ClassSessionRepository sessions, + StaffRepository staff, MembershipPlanRepository plans, + MembershipRepository memberships, MemberRepository members, + BookingRepository bookings, CheckInRepository checkIns, InvoiceRepository invoices, + WorkoutRepository workouts, FriendRepository friends, KudosRepository kudos, + ShiftRepository shifts, CampaignRepository campaigns, WalkInRepository walkIns, + ProfileRepository profiles, + @Value("${pulse.demo.zone:" + DEFAULT_ZONE + "}") String zone) { + this.zone = zone; + this.walkIns = walkIns; + this.profiles = profiles; + this.accounts = accounts; + this.config = config; + this.locations = locations; + this.rooms = rooms; + this.types = types; + this.sessions = sessions; + this.staff = staff; + this.plans = plans; + this.memberships = memberships; + this.members = members; + this.bookings = bookings; + this.checkIns = checkIns; + this.invoices = invoices; + this.workouts = workouts; + this.friends = friends; + this.kudos = kudos; + this.shifts = shifts; + this.campaigns = campaigns; + } + + @PostConstruct + public void seed() throws IOException { + accounts.seed(); + if (!config.isDevelopmentProfile() || seeded()) { + return; + } + write(); + } + + @Transactional(readOnly = true) + boolean seeded() { + return locations.existsById(DOWNTOWN); + } + + @Transactional(rollbackFor = IOException.class) + void write() throws IOException { + location(DOWNTOWN, "Pulse Downtown", "120 Market Street", 330, 1320); + location(RIVERSIDE, "Pulse Riverside", "8 Quay Road", 360, 1260); + room("room-studio-a", DOWNTOWN, "Studio A", 20); + room("room-ride", DOWNTOWN, "Ride Room", 24); + room("room-floor", DOWNTOWN, "Strength Floor", 14); + room("room-kids", DOWNTOWN, "Kids Zone", 12); + room("room-river", RIVERSIDE, "River Studio", 18); + room("room-wall", RIVERSIDE, "Climbing Wall", 10); + type(HIIT, "HIIT Burn", "Forty-five minutes of intervals: hard, rest, again.", + "STRENGTH", 5, 16, 0, 16, 45, "#E4572E"); + type("type-yoga", "Flow Yoga", "Breath-led vinyasa for every level.", "MIND", 2, 16, 0, + 20, 60, "#6C8EAD"); + type("type-ride", "Rhythm Ride", "Indoor cycling to the beat, lights down.", "CARDIO", 4, + 14, 0, 24, 45, "#F2A541"); + type("type-barbell", "Barbell Club", "Coached lifting: squat, press, pull.", "STRENGTH", + 4, 18, 0, 12, 60, "#3D5A80"); + type("type-pilates", "Core Pilates", "Mat Pilates for posture and core.", "MIND", 3, 16, + 0, 14, 50, "#9C6ADE"); + type("type-steady", "Steady & Strong", "Low impact strength and balance.", "CARDIO", 1, + 60, 0, 16, 45, "#8D99AE"); + type(LITTLE_MOVERS, "Little Movers", "Games, tumbling and balance for ages 4 to 7.", + "KIDS", 2, 4, 7, 12, 40, "#4CB944"); + type("type-kidsclimb", "Kids Climb", "Roped climbing with an instructor, 8 to 12.", + "KIDS", 3, 8, 12, 10, 60, "#FF6F59"); + type(TEEN, "Teen Strength", "Safe technique and confidence for 12 to 17.", "KIDS", 3, + 12, 17, 12, 50, "#2EC4B6"); + staffRow(DemoAccounts.TRAINER, DOWNTOWN, "Head coach", + "Strength and conditioning coach; ten years on the floor."); + staffRow(DemoAccounts.SECOND_TRAINER, RIVERSIDE, "Yoga and kids lead", + "Yoga teacher, climbing instructor and parent of two."); + staffRow(DemoAccounts.PILATES_TRAINER, DOWNTOWN, "Pilates and barre", + "Former dancer; teaches Pilates and the gentler strength classes."); + staffRow(DemoAccounts.RIDE_TRAINER, DOWNTOWN, "Ride and HIIT coach", + "Track cyclist turned coach; runs the evening rides."); + staffRow(DemoAccounts.RIVERSIDE_TRAINER, RIVERSIDE, "Yoga and climbing", + "Yoga teacher and route setter at the Riverside wall."); + plan(UNLIMITED, "Unlimited", "Every class, every location.", "MONTHLY", 8900L, 1, 0, 16, + 0, false); + plan(ANNUAL, "Unlimited Annual", "Twelve months for the price of ten.", "ANNUAL", + 89000L, 12, 0, 16, 0, false); + plan(OFF_PEAK, "Off-Peak", "Weekday middays and weekends.", "MONTHLY", 5900L, 1, 0, 16, + 0, true); + plan(PACK, "10-Class Pack", "Ten classes, six months to use them.", "CLASS_PACK", + 15000L, 6, 10, 14, 0, false); + plan(KIDS, "Kids Monthly", "Every kids' class, ages 4 to 17.", "MONTHLY", 4500L, 1, 0, + 4, 17, false); + plan(DROP_IN, "Drop-in", "One class, no commitment.", "DROP_IN", 2200L, 1, 1, 14, 0, + false); + timetable(); + households(); + liveClass(); + walkIns(); + shifts(); + campaigns(); + // The tests assert exact counts against the studio above -- members, + // bookings, invoices -- and both the JVM and the compiled ParparVM run + // of them seed a fresh database for every suite, so the crowd and its + // twelve weeks of history are for the servers people look at. + if (!"test".equals(config.getProfile())) { + crowd(); + rileysWeek(); + } + } + + /// Riley's next few days: a class tomorrow and two more after it, so the + /// watch's "coming up" page and the phone's bookings have a week in them. + /// Kept out of the tests' studio with the crowd, since it books places + /// the tests count. + private void rileysWeek() throws IOException { + Member riley = self(DemoAccounts.MEMBER); + if (riley == null) { + return; + } + Instant now = Instant.now(); + List ahead = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.plusMillis(12L * 3600000L), now.plusMillis(6L * Days.MILLIS)); + int booked = 0; + long last = 0; + // A different kind of class each time: three lunchtime yogas read as a seed. + StringBuilder kinds = new StringBuilder(); + for (int iter = 0; iter < ahead.size() && booked < 3; iter++) { + ClassSession session = ahead.get(iter); + String type = session.getClassTypeId(); + boolean likes = HIIT.equals(type) || "type-yoga".equals(type) + || "type-pilates".equals(type); + // One a day at most, and only where there is room. + if (likes && kinds.indexOf(type + ";") < 0 + && DOWNTOWN.equals(session.getLocationId()) + && session.getBooked() < session.getCapacity() + && session.getStartsAt().toEpochMilli() - last > 20L * 3600000L + && bookings.findFirstBySessionIdAndMemberId(session.getId(), + riley.getId()) == null) { + book(riley, session); + last = session.getStartsAt().toEpochMilli(); + kinds.append(type).append(';'); + booked++; + } + } + } + + /// A HIIT of Tess's that started a quarter of an hour ago, whatever the + /// time the server comes up, with six people checked in: the trainer's + /// live screen has a roster, and [DemoHeartRates] gives it pulses. + private void liveClass() throws IOException { + Instant now = Instant.now(); + ClassSession live = session(now.minusMillis(15L * 60000L), HIIT, DOWNTOWN, + "room-studio-a", DemoAccounts.TRAINER); + LocalDate today = LocalDate.now(ZoneId.of(zone)); + attended(self(DemoAccounts.SECOND_MEMBER), live, true); + String[] names = {"Jordan Reyes", "Priya Natarajan", "Marcus Bell", "Hannah Okafor", + "Sam Whitfield", "Elena Rossi", "Daniel Kim", "Grace Mensah", "Tom Fischer", + "Aisha Rahman"}; + int[] ages = {27, 34, 45, 22, 58, 31, 39, 26, 49, 36}; + // Start days that fall on different days of the billing month, so the + // owner's revenue chart is not one bar. + int[] joined = {37, 52, 98, 143, 176, 12, 64, 121, 158, 201}; + List past = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.minusMillis(WEEKS_BACK * 7L * Days.MILLIS), now); + for (int iter = 0; iter < names.length; iter++) { + // Households with no sign-in of their own: members the desk + // signed up, so the roster is not only the demo accounts. + Member row = new Member(); + row.setId("member-guest-" + (iter + 1)); + row.setAccount("demo-guest-" + (iter + 1) + "@pulse.example"); + row.setName(names[iter]); + row.setBirthDate(today.minusYears(ages[iter]).minusDays(30L * iter + 11L)); + row.setRelation("SELF"); + row.setCreatedAt(now.minusMillis((200L + 40L * iter) * Days.MILLIS)); + sign(row); + members.insert(row); + // A profile, as signing up would have made: the friends list, the + // feed and kudos name a person by it. + Profile profile = new Profile(); + profile.setUsername(row.getAccount()); + profile.setDisplayName(names[iter]); + profile.setCreatedAt(row.getCreatedAt()); + profiles.insert(profile); + Membership plan = membership(row, iter == 3 ? OFF_PEAK : UNLIMITED, "ACTIVE", + joined[iter], 0, now); + // The odd drop-in for a friend and a class pack on top, on days + // of their own. + purchase(plan, row, DROP_IN, 2 + 5 * iter, now); + purchase(plan, row, iter % 2 == 0 ? DROP_IN : PACK, 4 + 6 * iter, now); + for (int each = 0; each < past.size(); each++) { + ClassSession session = past.get(each); + if (session.getEndsAt().isBefore(now) && DOWNTOWN.equals(session.getLocationId()) + && !LITTLE_MOVERS.equals(session.getClassTypeId()) + && !TEEN.equals(session.getClassTypeId()) && next(9) == 0) { + attended(row, session, next(10) != 0); + } + } + attended(row, live, true); + // Three of them train with Riley too, so the friends' feed is a + // handful of people and classes rather than one friend's week. + if (iter == 0 || iter == 1 || iter == 3) { + befriend(DemoAccounts.MEMBER, row.getAccount(), now); + befriend(row.getAccount(), DemoAccounts.MEMBER, now); + } + } + } + + /// This morning's walk-ins at the downtown desk, so the desk's day has + /// people and takings in it: a day pass, a guest of a member and a free + /// trial, the latest a few minutes ago. + private void walkIns() throws IOException { + Instant now = Instant.now(); + Instant midnight = LocalDate.now(ZoneId.of(zone)).atStartOfDay(ZoneId.of(zone)).toInstant(); + String[] names = {"Alex Romero", "Noor Haddad", "Ben Kowalski"}; + String[] kinds = {"DAY_PASS", "GUEST", "TRIAL"}; + String[] paidBy = {"CARD", "NONE", "NONE"}; + long[] cents = {2500L, 0L, 0L}; + long[] minutesAgo = {95L, 40L, 6L}; + for (int iter = 0; iter < names.length; iter++) { + Instant at = now.minusMillis(minutesAgo[iter] * 60000L); + if (at.isBefore(midnight)) { + continue; + } + WalkIn row = new WalkIn(); + row.setId(Ids.next()); + row.setLocationId(DOWNTOWN); + row.setName(names[iter]); + row.setEmail(""); + row.setPhone(""); + row.setKind(kinds[iter]); + row.setAmountCents(cents[iter]); + row.setPaidBy(paidBy[iter]); + row.setStaff(DemoAccounts.DESK); + row.setAt(at); + walkIns.insert(row); + } + } + + /// A week of shifts for both trainers, one of Theo's offered for swap so + /// Tess has something under "Offered by others". + private void shifts() throws IOException { + LocalDate today = LocalDate.now(ZoneId.of(zone)); + for (int day = 1; day <= 7; day++) { + LocalDate on = today.plusDays(day); + if (on.getDayOfWeek() != DayOfWeek.SUNDAY) { + shift(DemoAccounts.TRAINER, on, 6, 6, day % 2 == 0 ? "Classes" : "Floor", + Trainers.ASSIGNED); + } + if (day % 2 == 1) { + shift(DemoAccounts.SECOND_TRAINER, on, 12, 5, day == 3 ? "Front desk" : "Classes", + day == 3 ? Trainers.SWAP_REQUESTED : Trainers.ASSIGNED); + } + } + } + + /// A one-off purchase -- a drop-in or a class pack -- paid `daysAgo`. + private void purchase(Membership membership, Member member, String planId, int daysAgo, + Instant now) throws IOException { + if (membership == null) { + return; + } + MembershipPlan plan = plans.findById(planId); + Instant at = now.minusMillis(daysAgo * Days.MILLIS + 3L * 3600000L); + Invoice row = invoice(membership, member, plan.getName(), plan.getPriceCents(), at, at); + row.setStatus("PAID"); + row.setAttempts(1); + row.setChargeRef("demo-" + row.getId().substring(0, 8)); + row.setMethodId(""); + row.setPaidAt(at); + invoices.insert(row); + } + + /// Three campaigns the owner sent over the last weeks: filling a quiet + /// Barbell Club, calling back the lapsed, and a referral offer. + private void campaigns() throws IOException { + Instant now = Instant.now(); + List past = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.minusMillis(14L * Days.MILLIS), now); + String barbell = ""; + for (int iter = 0; iter < past.size() && barbell.length() == 0; iter++) { + if ("type-barbell".equals(past.get(iter).getClassTypeId())) { + barbell = past.get(iter).getId(); + } + } + campaign("Fill Barbell Club", "CLASS", barbell, "Four places left at Barbell Club", + "Squat, press, pull with Tess tonight. Book before it fills.", 12, 38, 29, 6); + campaign("We miss you", "LAPSED", "", "Your mat misses you", + "It has been a while. Come back this week, your first class is on us.", 9, 22, + 14, 3); + campaign("Bring a friend", "ALL", "", "Train with a friend, both get a free week", + "Share your code: when they join, you both get seven days free.", 3, 146, 97, + 11); + } + + private void campaign(String name, String kind, String sessionId, String title, String body, + int daysAgo, int sent, int opened, int booked) throws IOException { + Campaign row = new Campaign(); + row.setId(Ids.next()); + row.setName(name); + row.setKind(kind); + row.setSessionId(sessionId); + row.setTitle(title); + row.setBody(body); + row.setCreatedBy(DemoAccounts.OWNER); + row.setCreatedAt(Instant.now().minusMillis(daysAgo * Days.MILLIS + 5L * 3600000L)); + row.setSent(sent); + row.setOpened(opened); + row.setBooked(booked); + campaigns.insert(row); + } + + private void shift(String trainer, LocalDate day, int hour, int hours, String duty, + String state) throws IOException { + Instant start = ZonedDateTime.of(day, LocalTime.of(hour, 0), ZoneId.of(zone)).toInstant(); + Shift row = new Shift(); + row.setId(Ids.next()); + row.setLocationId(DOWNTOWN); + row.setStaff(trainer); + row.setDuty(duty); + row.setStartsAt(start); + row.setEndsAt(start.plusMillis(hours * 3600000L)); + row.setState(state); + shifts.insert(row); + } + + private void timetable() throws IOException { + LocalDate today = LocalDate.now(ZoneId.of(zone)); + timetable(Studio.monday(today.minusWeeks(WEEKS_BACK)), today.plusWeeks(WEEKS_AHEAD)); + } + + /// The weekly timetable on every day from `first` to `last`, both included. + private void timetable(LocalDate first, LocalDate last) throws IOException { + String tess = DemoAccounts.TRAINER; + String theo = DemoAccounts.SECOND_TRAINER; + String maya = DemoAccounts.PILATES_TRAINER; + String andre = DemoAccounts.RIDE_TRAINER; + String sofia = DemoAccounts.RIVERSIDE_TRAINER; + for (LocalDate day = first; !day.isAfter(last); day = day.plusDays(1)) { + DayOfWeek dow = day.getDayOfWeek(); + boolean weekday = dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY; + if (weekday) { + session(day, 6, 30, HIIT, DOWNTOWN, "room-studio-a", tess); + session(day, 7, 30, "type-ride", DOWNTOWN, "room-ride", andre); + session(day, 12, 15, "type-yoga", DOWNTOWN, "room-studio-a", theo); + session(day, 17, 30, "type-barbell", DOWNTOWN, "room-floor", tess); + session(day, 18, 30, "type-ride", DOWNTOWN, "room-ride", andre); + session(day, 19, 30, "type-pilates", DOWNTOWN, "room-studio-a", maya); + session(day, 7, 0, "type-yoga", RIVERSIDE, "room-river", sofia); + session(day, 18, 0, HIIT, RIVERSIDE, "room-river", andre); + } + if (dow == DayOfWeek.TUESDAY || dow == DayOfWeek.THURSDAY) { + session(day, 16, 0, LITTLE_MOVERS, DOWNTOWN, "room-kids", theo); + session(day, 16, 30, TEEN, DOWNTOWN, "room-floor", tess); + } + if (dow == DayOfWeek.WEDNESDAY) { + session(day, 10, 0, "type-steady", DOWNTOWN, "room-studio-a", maya); + } + if (dow == DayOfWeek.SATURDAY) { + session(day, 9, 0, LITTLE_MOVERS, DOWNTOWN, "room-kids", theo); + session(day, 10, 0, "type-kidsclimb", RIVERSIDE, "room-wall", sofia); + session(day, 11, 0, TEEN, DOWNTOWN, "room-floor", tess); + session(day, 9, 30, "type-yoga", RIVERSIDE, "room-river", sofia); + } + if (dow == DayOfWeek.SUNDAY) { + session(day, 10, 0, "type-ride", DOWNTOWN, "room-ride", andre); + } + } + } + + /// The demo accounts' memberships, children and the visits behind them. + private void households() throws IOException { + Instant now = Instant.now(); + String riley = DemoAccounts.MEMBER; + LocalDate today = LocalDate.now(ZoneId.of(zone)); + Member rileySelf = self(riley); + Member kai = child(KAI, riley, "Kai Park", today.minusYears(6).minusDays(40)); + Member mia = child(MIA, riley, "Mia Park", today.minusYears(13).minusDays(100)); + sign(rileySelf); + sign(kai); + sign(mia); + membership(rileySelf, UNLIMITED, "ACTIVE", 124, 0, now); + membership(kai, KIDS, "ACTIVE", 96, 0, now); + membership(mia, KIDS, "ACTIVE", 103, 0, now); + Member rae = self(DemoAccounts.FLAGGED_MEMBER); + sign(rae); + Membership raes = membership(rae, OFF_PEAK, "PAST_DUE", 60, 0, now); + overdue(raes, 5900L, now); + // Robin trains with Riley: friends both ways, so each sees the + // other's classes in the feed and on the timetable. + Member robin = self(DemoAccounts.SECOND_MEMBER); + if (robin != null) { + sign(robin); + membership(robin, UNLIMITED, "ACTIVE", 289, 0, now); + if (rileySelf != null) { + befriend(rileySelf.getAccount(), robin.getAccount(), now); + befriend(robin.getAccount(), rileySelf.getAccount(), now); + } + } + Member lee = self(DemoAccounts.LAPSED_MEMBER); + sign(lee); + membership(lee, PACK, "ACTIVE", 200, 4, now); + // The visits: Riley twice a week or so, the children at their + // Saturday and after-school classes, Lee not for six weeks. + List past = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.minusMillis((WEEKS_BACK * 7L + 7L) * Days.MILLIS), now); + for (int iter = 0; iter < past.size(); iter++) { + ClassSession session = past.get(iter); + if (session.getEndsAt().isAfter(now)) { + continue; + } + String type = session.getClassTypeId(); + if (rileySelf != null && (HIIT.equals(type) || "type-yoga".equals(type)) + && DOWNTOWN.equals(session.getLocationId()) && next(5) == 0) { + attended(rileySelf, session, next(9) != 0); + } + if (robin != null && (HIIT.equals(type) || "type-ride".equals(type)) + && DOWNTOWN.equals(session.getLocationId()) && next(4) == 0) { + attended(robin, session, true); + } + if (kai != null && LITTLE_MOVERS.equals(type) && next(3) != 0) { + attended(kai, session, true); + } + if (mia != null && TEEN.equals(type) && next(2) == 0) { + attended(mia, session, true); + } + if (rae != null && "type-ride".equals(type) && next(12) == 0) { + attended(rae, session, false); + } + } + // Classes to come: Riley and the children are booked into a few, and + // one early HIIT is made small and full, so the waitlist has a queue + // to show. + List ahead = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + now.plusMillis(Days.MILLIS), now.plusMillis(8L * Days.MILLIS)); + boolean full = false; + for (int iter = 0; iter < ahead.size(); iter++) { + ClassSession session = ahead.get(iter); + String type = session.getClassTypeId(); + if (!full && HIIT.equals(type) && DOWNTOWN.equals(session.getLocationId())) { + session.setCapacity(2); + session.setNote("Small group: two places"); + book(rae, session); + book(lee, session); + full = true; + } else if (LITTLE_MOVERS.equals(type) && session.getBooked() == 0 && next(2) == 0) { + book(kai, session); + } else if ("type-yoga".equals(type) && DOWNTOWN.equals(session.getLocationId()) + && next(4) == 0) { + book(rileySelf, session); + } + } + } + + private void location(String id, String name, String address, int opens, int closes) { + Location row = new Location(); + row.setId(id); + row.setName(name); + row.setAddress(address); + row.setTimeZone(zone); + row.setOpensMinute(opens); + row.setClosesMinute(closes); + locations.insert(row); + } + + private void room(String id, String location, String name, int capacity) { + Room row = new Room(); + row.setId(id); + row.setLocationId(location); + row.setName(name); + row.setCapacity(capacity); + rooms.insert(row); + } + + private void type(String id, String name, String description, String category, + int intensity, int minAge, int maxAge, int capacity, int minutes, String color) { + ClassType row = new ClassType(); + row.setId(id); + row.setName(name); + row.setDescription(description); + row.setCategory(category); + row.setIntensity(intensity); + row.setMinAge(minAge); + row.setMaxAge(maxAge); + row.setCapacity(capacity); + row.setMinutes(minutes); + row.setColor(color); + row.setActive(true); + types.insert(row); + } + + private void staffRow(String username, String location, String title, String bio) { + Staff row = staff.findById(username); + if (row == null) { + return; + } + row.setLocationId(location); + row.setTitle(title); + row.setBio(bio); + row.setCertifiedUntil(Instant.now().plusMillis(300L * Days.MILLIS)); + } + + private void plan(String id, String name, String description, String kind, long cents, + int months, int credits, int minAge, int maxAge, boolean offPeak) { + MembershipPlan row = new MembershipPlan(); + row.setId(id); + row.setName(name); + row.setDescription(description); + row.setKind(kind); + row.setPriceCents(cents); + row.setCurrency("USD"); + row.setPeriodMonths(months); + row.setClassCredits(credits); + row.setMinAge(minAge); + row.setMaxAge(maxAge); + row.setOffPeakOnly(offPeak); + row.setActive(true); + plans.insert(row); + } + + private void session(LocalDate day, int hour, int minute, String typeId, String location, + String room, String trainer) throws IOException { + session(ZonedDateTime.of(day, LocalTime.of(hour, minute), ZoneId.of(zone)).toInstant(), + typeId, location, room, trainer); + } + + private ClassSession session(Instant start, String typeId, String location, String room, + String trainer) throws IOException { + ClassType type = types.findById(typeId); + ClassSession row = new ClassSession(); + row.setId(Ids.next()); + row.setClassTypeId(typeId); + row.setLocationId(location); + row.setRoomId(room); + row.setTrainer(trainer); + row.setStartsAt(start); + row.setEndsAt(start.plusMillis(type.getMinutes() * 60000L)); + row.setCapacity(type.getCapacity()); + row.setState(start.plusMillis(type.getMinutes() * 60000L + Bookings.SETTLE_MILLIS) + .isBefore(Instant.now()) ? Studio.DONE : Studio.SCHEDULED); + sessions.insert(row); + return row; + } + + private Member self(String account) { + return members.findFirstByAccountAndRelation(account, "SELF"); + } + + private Member child(String id, String account, String name, LocalDate born) { + if (self(account) == null) { + return null; + } + Member row = new Member(); + row.setId(id); + row.setAccount(account); + row.setName(name); + row.setBirthDate(born); + row.setRelation("CHILD"); + row.setMedicalNote(KAI.equals(id) ? "Mild asthma; inhaler in bag" : ""); + row.setCreatedAt(Instant.now().minusMillis(90L * Days.MILLIS)); + members.insert(row); + return row; + } + + private static void sign(Member member) { + if (member != null) { + member.setWaiverAt(member.getCreatedAt()); + } + } + + /// A membership started `daysAgo`, with a paid invoice for every period + /// since. + private Membership membership(Member member, String planId, String state, int daysAgo, + int creditsLeft, Instant now) throws IOException { + return membership(member, planId, state, daysAgo, creditsLeft, now, now); + } + + /// A membership whose bills stop with the period running at `stop`: the + /// day a cancelled one was cancelled, or now. + private Membership membership(Member member, String planId, String state, int daysAgo, + int creditsLeft, Instant now, Instant stop) throws IOException { + if (member == null) { + return null; + } + MembershipPlan plan = plans.findById(planId); + Instant started = now.minusMillis(daysAgo * Days.MILLIS); + Membership row = new Membership(); + row.setId(Ids.next()); + row.setMemberId(member.getId()); + row.setAccount(member.getAccount()); + row.setPlanId(planId); + row.setState(state); + row.setStartedAt(started); + row.setCreditsLeft(creditsLeft); + row.setUpdatedAt(started); + long periodMillis = plan.getPeriodMonths() * 30L * Days.MILLIS; + Instant periodStart = started; + Instant periodEnd = started.plusMillis(periodMillis); + boolean recurring = !Household.counted(plan); + while (true) { + if (!"PAST_DUE".equals(state) || periodEnd.isBefore(now)) { + paid(row, member, plan, periodStart, periodEnd); + } + if (!recurring || periodEnd.isAfter(stop)) { + break; + } + periodStart = periodEnd; + periodEnd = periodEnd.plusMillis(periodMillis); + } + row.setCurrentPeriodEnd(periodEnd); + memberships.insert(row); + return row; + } + + private void paid(Membership membership, Member member, MembershipPlan plan, Instant from, + Instant to) throws IOException { + Invoice row = invoice(membership, member, plan.getName(), plan.getPriceCents(), from, to); + row.setStatus("PAID"); + row.setAttempts(1); + row.setChargeRef("demo-" + row.getId().substring(0, 8)); + row.setMethodId(""); + row.setPaidAt(from.plusMillis(60000L)); + invoices.insert(row); + } + + private void overdue(Membership membership, long cents, Instant now) throws IOException { + if (membership == null) { + return; + } + Member member = members.findById(membership.getMemberId()); + Invoice row = invoice(membership, member, "Off-Peak", cents, + now.minusMillis(4L * Days.MILLIS), now.plusMillis(26L * Days.MILLIS)); + row.setStatus("FAILED"); + row.setAttempts(2); + row.setFailure("Card declined: insufficient funds"); + row.setNextAttemptAt(now.plusMillis(2L * Days.MILLIS)); + invoices.insert(row); + } + + private Invoice invoice(Membership membership, Member member, String what, long cents, + Instant from, Instant to) throws IOException { + Invoice row = new Invoice(); + row.setId(Ids.next()); + row.setAccount(membership.getAccount()); + row.setMembershipId(membership.getId()); + row.setMemberId(membership.getMemberId()); + row.setDescription(what + " for " + (member == null ? "" : member.getName())); + row.setAmountCents(cents); + row.setCurrency("USD"); + row.setPeriodStart(from); + row.setPeriodEnd(to); + row.setCreatedAt(from); + return row; + } + + /// A past class, attended -- or booked and missed, for `came` false. + private void attended(Member member, ClassSession session, boolean came) throws IOException { + attended(member, session, came, true); + } + + /// The same, with the workout an adult recorded left out for `record` + /// false. + private void attended(Member member, ClassSession session, boolean came, boolean record) + throws IOException { + if (member == null) { + return; + } + Booking row = booking(member, session, came ? Bookings.CHECKED_IN : Bookings.NO_SHOW); + session.setBooked(session.getBooked() + 1); + if (came) { + Instant at = session.getStartsAt().minusMillis(5L * 60000L); + row.setCheckedInAt(at); + CheckIn visit = new CheckIn(); + visit.setId(Ids.next()); + visit.setMemberId(member.getId()); + visit.setAccount(member.getAccount()); + visit.setLocationId(session.getLocationId()); + visit.setMethod(next(3) == 0 ? "NFC" : "QR"); + visit.setSessionId(session.getId()); + visit.setAt(at); + visit.setStaff(DemoAccounts.DESK); + checkIns.insert(visit); + if (record && "SELF".equals(member.getRelation()) + && session.getEndsAt().isBefore(Instant.now())) { + worked(member, session); + } + } + bookings.insert(row); + } + + /// The workout an adult recorded in a class they came to. Most wore a + /// strap or a watch; some only had the phone's estimate, and a few + /// recorded nothing at all, so the points and badges have something to + /// tell apart. + private void worked(Member member, ClassSession session) throws IOException { + int kind = next(6); + if (kind == 0) { + return; + } + int minutes = (int) ((session.getEndsAt().toEpochMilli() + - session.getStartsAt().toEpochMilli()) / 60000L); + int avg = kind == 1 ? 0 : 118 + next(30); + int peak = avg == 0 ? 0 : avg + 18 + next(20); + Workout row = new Workout(); + row.setId(Ids.next()); + row.setAccount(member.getAccount()); + row.setSessionId(session.getId()); + String name = className(session); + row.setActivity(name == null ? "Class" : name); + row.setStartedAt(session.getStartsAt()); + row.setMinutes(minutes); + row.setAvgHr(avg); + row.setMaxHr(peak); + row.setCalories(minutes * (avg == 0 ? 6 : avg / 18)); + if (avg == 0) { + row.setZones("0,0,0,0,0"); + } else { + int top = peak > 175 ? 2 + next(4) : 0; + row.setZones((minutes / 6) + "," + (minutes / 5) + "," + (minutes / 4) + "," + + (minutes - minutes / 6 - minutes / 5 - minutes / 4 - top) + "," + top); + } + row.setSource(kind == 1 ? "HEALTH" : kind == 2 ? "WATCH" : "STRAP"); + row.setShared(kind != 5); + row.setCreatedAt(session.getEndsAt()); + workouts.insert(row); + // A shared workout gathers a little applause from the class, so the + // friends' feed shows what kudos look like. + int cheers = row.isShared() ? next(4) : 0; + for (int iter = 0; iter < cheers; iter++) { + Kudos one = new Kudos(); + one.setId(Ids.next()); + // The guests seeded in liveClass(): members with an account of + // their own, so a feed that names the sender finds them. + String from = "demo-guest-" + (iter + 1) + "@pulse.example"; + if (from.equals(member.getAccount())) { + // Nobody cheers their own workout. + from = "demo-guest-" + (iter + 6) + "@pulse.example"; + } + one.setFromAccount(from); + one.setToAccount(member.getAccount()); + one.setWorkoutId(row.getId()); + one.setCreatedAt(session.getEndsAt().plusSeconds(600L * (iter + 1))); + kudos.insert(one); + } + } + + private void befriend(String account, String other, Instant at) throws IOException { + Friend row = new Friend(); + row.setId(Ids.next()); + row.setAccount(account); + row.setFriend(other); + row.setState("ACCEPTED"); + row.setCreatedAt(at); + friends.insert(row); + } + + private String className(ClassSession session) { + ClassType type = types.findById(session.getClassTypeId()); + return type == null ? null : type.getName(); + } + + private void book(Member member, ClassSession session) throws IOException { + if (member == null) { + return; + } + boolean room = session.getBooked() < session.getCapacity(); + Booking row = booking(member, session, room ? Bookings.BOOKED : Bookings.WAITLISTED); + if (room) { + session.setBooked(session.getBooked() + 1); + } else { + session.setWaitlisted(session.getWaitlisted() + 1); + row.setQueuePosition(session.getWaitlisted()); + } + bookings.insert(row); + } + + private static Booking booking(Member member, ClassSession session, String state) + throws IOException { + Booking row = new Booking(); + row.setId(Ids.next()); + row.setSessionId(session.getId()); + row.setMemberId(member.getId()); + row.setAccount(member.getAccount()); + row.setState(state); + Instant made = session.getStartsAt().minusMillis(2L * Days.MILLIS); + row.setCreatedAt(made); + row.setUpdatedAt(made); + return row; + } + + // ----------------------------------------------------------------- crowd + + // Sixty of each, so a first name comes round at most twice in the crowd + // and an alphabetical list does not read as one name over and over. + private static final String[] FIRST = {"Alex", "Bea", "Carlos", "Dana", "Eli", "Fatima", + "Gabe", "Hana", "Ivan", "Jade", "Kofi", "Lena", "Mateo", "Nora", "Omar", "Paula", + "Quinn", "Rosa", "Sven", "Tara", "Umar", "Vera", "Wes", "Yusuf", "Zoe", "Amara", + "Ben", "Chloe", "Diego", "Emma", "Farid", "Greta", "Hugo", "Ines", "Jonas", "Kira", + "Luca", "Maya", "Nikhil", "Olivia", "Pablo", "Rina", "Sara", "Tomas", "Uma", "Victor", + "Wendy", "Yara", "Zain", "Anya", "Bruno", "Celine", "Dev", "Esme", "Felix", "Gia", + "Hiro", "Isla", "Jamal", "Keira"}; + private static final String[] LAST = {"Abbott", "Baker", "Castillo", "Dubois", "Evans", + "Ferreira", "Gupta", "Holm", "Ibrahim", "Jensen", "Kowalski", "Larsen", "Mendez", + "Nakamura", "Olsen", "Petrov", "Quigley", "Ruiz", "Sato", "Tanaka", "Ueda", "Varga", + "Walsh", "Young", "Zhang", "Adeyemi", "Bauer", "Costa", "Novak", "Duarte", "Eriksen", + "Fontaine", "Garcia", "Hughes", "Iyer", "Jovanovic", "Khan", "Lindgren", "Moreau", + "Nguyen", "O'Brien", "Pham", "Quinlan", "Rossi", "Silva", "Torres", "Vogel", "Weber", + "Yilmaz", "Ziegler", "Alvarado", "Brennan", "Chowdhury", "Dalton", "Esposito", + "Fischer", "Greene", "Haddad", "Ito", "Keller"}; + private static final String[] KIDS_NAMES = {"Leo", "Ava", "Max", "Ivy", "Noah", "Lily", + "Finn", "Ruby", "Theo", "Nina", "Otto", "Mila"}; + /// The kinds of class each sort of regular comes for. The evening crowd + /// rides and does HIIT, which is why those classes fill. + private static final String[][] TASTES = { + {"type-ride", HIIT, "type-ride"}, + {HIIT, "type-barbell", "type-ride"}, + {"type-yoga", "type-pilates"}, + {"type-yoga", "type-yoga", "type-steady"}, + {"type-yoga", "type-ride"}, + }; + static final int EARLY = 0; + static final int LUNCH = 1; + static final int EVENING = 2; + static final int WEEKEND = 3; + static final int CROWD = 110; + static final int HISTORY_WEEKS = 12; + + /// One synthetic member's habit: what they come to, when, how often, and + /// the days between which they came. + private static final class Regular { + Member member; + String[] tastes; + int band; + int perWeekTenths; + Instant from; + Instant until; + int matches; + } + + /// About a hundred and ten more members with a year of joining, a few + /// months of leaving and twelve weeks of classes, so the owner's + /// insights have a studio to describe. Every number comes from the fixed + /// sequence, so every demo database tells the same story. + private void crowd() throws IOException { + Instant now = Instant.now(); + ZoneId here = ZoneId.of(zone); + LocalDate today = LocalDate.now(here); + // Nine older weeks of the same timetable, before the three every + // database has. + LocalDate oldest = Studio.monday(today.minusWeeks(HISTORY_WEEKS - 1)); + LocalDate base = Studio.monday(today.minusWeeks(WEEKS_BACK)); + timetable(oldest, base.minusDays(1)); + List regulars = new ArrayList(); + int cancelled = 0; + int frozen = 0; + int pastDue = 0; + int kids = 0; + for (int iter = 0; iter < CROWD; iter++) { + String first = FIRST[iter % FIRST.length]; + String last = LAST[(iter * 37 + iter / FIRST.length * 7 + 11) % LAST.length]; + String name = first + " " + last; + String account = "crowd-" + (iter + 1) + "@pulse.example"; + // More joined lately than a year ago: the square root bunches + // the days towards now. + double spread = Math.sqrt(next(10000) / 10000.0); + int daysAgo = 2 + (int) (360 * (1.0 - spread)); + Member self = new Member(); + self.setId("member-crowd-" + (iter + 1)); + self.setAccount(account); + self.setName(name); + self.setBirthDate(today.minusYears(19 + next(45)).minusDays(next(365))); + self.setRelation("SELF"); + self.setCreatedAt(now.minusMillis(daysAgo * Days.MILLIS)); + sign(self); + members.insert(self); + Profile profile = new Profile(); + profile.setUsername(account); + profile.setDisplayName(name); + profile.setCreatedAt(self.getCreatedAt()); + profiles.insert(profile); + + Regular habit = new Regular(); + habit.member = self; + int taste = next(20); + habit.tastes = TASTES[taste < 9 ? 0 : taste < 13 ? 1 : taste < 17 ? 2 : taste < 19 + ? 3 : 4]; + habit.band = habit.tastes == TASTES[3] ? LUNCH : habit.tastes == TASTES[4] ? WEEKEND + : next(10) < (habit.tastes == TASTES[2] ? 4 : 2) ? EARLY : EVENING; + // The evening crowd comes most, two to four times a week. + habit.perWeekTenths = habit.band == EVENING ? 20 + next(21) : 5 + next(36); + habit.from = self.getCreatedAt(); + habit.until = now; + + // The plan: mostly Unlimited, then Off-Peak for the daytime + // crowd, Annual, and a few packs; one in eight is a parent who + // signed a child up instead. + int pick = next(100); + if (pick < 14 && kids < KIDS_NAMES.length) { + // Made here rather than by child(), which looks the parent up + // and would not find one inserted in this transaction yet. + Member child = new Member(); + child.setId("member-crowd-kid-" + (kids + 1)); + child.setAccount(account); + child.setName(KIDS_NAMES[kids] + " " + last); + child.setBirthDate(today.minusYears(5 + next(10))); + child.setRelation("CHILD"); + child.setCreatedAt(self.getCreatedAt()); + sign(child); + members.insert(child); + kids++; + membership(child, KIDS, "ACTIVE", daysAgo, 0, now); + Regular young = new Regular(); + young.member = child; + young.tastes = new String[] {LITTLE_MOVERS, "type-kidsclimb", TEEN}; + young.band = WEEKEND; + young.perWeekTenths = 10 + next(11); + young.from = habit.from; + young.until = now; + regulars.add(young); + continue; + } + String plan = pick < 55 ? UNLIMITED : pick < 75 ? OFF_PEAK : pick < 88 ? ANNUAL + : PACK; + if (OFF_PEAK.equals(plan) && habit.band != LUNCH && habit.band != WEEKEND) { + // Off-Peak is weekday middays and weekends: its members come + // then, for what is on then. + boolean lunch = next(2) == 0; + habit.band = lunch ? LUNCH : WEEKEND; + habit.tastes = lunch ? TASTES[3] : TASTES[4]; + habit.perWeekTenths = 5 + next(21); + } + String state = "ACTIVE"; + Instant stop = now; + if (cancelled < 15 && daysAgo > 40 && iter % 5 == 1) { + // Left at some point in the last ninety days. + cancelled++; + state = "CANCELLED"; + stop = now.minusMillis((3L + next(Math.min(87, daysAgo - 30))) * Days.MILLIS); + habit.until = stop; + } else if (frozen < 4 && daysAgo > 45 && iter % 7 == 4) { + frozen++; + state = "FROZEN"; + habit.until = now.minusMillis((5L + next(10)) * Days.MILLIS); + } else if (pastDue < 3 && OFF_PEAK.equals(plan) && daysAgo > 35 && iter % 5 == 3) { + pastDue++; + state = "PAST_DUE"; + } else if (iter % 13 == 5 && daysAgo > 30) { + // At risk: has not been in for two weeks or more. + habit.until = now.minusMillis((15L + next(15)) * Days.MILLIS); + } + Membership row = membership(self, plan, state, daysAgo, + PACK.equals(plan) ? 2 + next(7) : 0, now, stop); + if ("CANCELLED".equals(state)) { + row.setUpdatedAt(stop); + } else if ("FROZEN".equals(state)) { + row.setFrozenFrom(habit.until); + row.setFrozenUntil(habit.until.plusMillis(30L * Days.MILLIS)); + row.setUpdatedAt(habit.until); + } else if ("PAST_DUE".equals(state)) { + overdue(row, 5900L, now); + row.setUpdatedAt(now.minusMillis(4L * Days.MILLIS)); + } + regulars.add(habit); + } + + // The desk's guests from liveClass() kept their habit in the older + // weeks too: half the adult classes Downtown, up to where their + // history there begins. + List older = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + oldest.atStartOfDay(here).toInstant(), + now.minusMillis(WEEKS_BACK * 7L * Days.MILLIS)); + for (int iter = 0; iter < 10; iter++) { + Member guest = members.findById("member-guest-" + (iter + 1)); + for (int each = 0; guest != null && each < older.size(); each++) { + ClassSession session = older.get(each); + if (session.getStartsAt().isAfter(guest.getCreatedAt()) + && DOWNTOWN.equals(session.getLocationId()) + && !LITTLE_MOVERS.equals(session.getClassTypeId()) + && !TEEN.equals(session.getClassTypeId()) && next(9) == 0) { + attended(guest, session, next(10) != 0, next(5) == 0); + } + } + } + + // The classes: each regular comes to about as many a week as their + // habit says, spread over the classes that suit them, while there + // is a place. + List all = sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + oldest.atStartOfDay(here).toInstant(), now.plusMillis(8L * Days.MILLIS)); + for (int each = 0; each < all.size(); each++) { + ClassSession session = all.get(each); + int band = band(session, here); + for (int iter = 0; iter < regulars.size(); iter++) { + Regular habit = regulars.get(iter); + if (suits(habit, session, band) && session.getStartsAt().isAfter(habit.from) + && session.getStartsAt().isBefore(habit.until)) { + habit.matches++; + } + } + } + for (int each = 0; each < all.size(); each++) { + ClassSession session = all.get(each); + boolean over = session.getEndsAt().isBefore(now); + boolean ahead = session.getStartsAt().isAfter(now); + if (!over && !ahead || !Studio.DONE.equals(session.getState()) && !ahead) { + continue; + } + int band = band(session, here); + for (int iter = 0; iter < regulars.size(); iter++) { + Regular habit = regulars.get(iter); + if (session.getBooked() >= session.getCapacity()) { + break; + } + if (!suits(habit, session, band) || habit.matches == 0) { + continue; + } + Instant start = session.getStartsAt(); + if (!start.isAfter(habit.from) || over && !start.isBefore(habit.until) + || ahead && habit.until.isBefore(now)) { + continue; + } + long weeks = Math.max(1L, (habit.until.toEpochMilli() + - habit.from.toEpochMilli()) / (7L * Days.MILLIS)); + long active = Math.min(weeks, HISTORY_WEEKS + 1); + // Visits wanted over the classes that suit, in thousandths. + long chance = habit.perWeekTenths * active * 100L / habit.matches; + if (next(1000) >= chance) { + continue; + } + if (ahead) { + book(habit.member, session); + } else { + attended(habit.member, session, next(12) != 0, next(5) == 0); + } + } + } + } + + /// Early, lunch, evening, or the weekend, on the studio's clock. + private static int band(ClassSession session, ZoneId here) { + ZonedDateTime local = session.getStartsAt().atZone(here); + DayOfWeek dow = local.getDayOfWeek(); + if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { + return WEEKEND; + } + int hour = local.getHour(); + return hour < 9 ? EARLY : hour < 15 ? LUNCH : EVENING; + } + + private static boolean suits(Regular habit, ClassSession session, int band) { + if (habit.band != band) { + return false; + } + for (int iter = 0; iter < habit.tastes.length; iter++) { + if (habit.tastes[iter].equals(session.getClassTypeId())) { + return true; + } + } + return false; + } + + /// The next number in `[0, bound)` of the fixed sequence. + private int next(int bound) { + seed = seed * 6364136223846793005L + 1442695040888963407L; + return (int) ((seed >>> 33) % bound); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Household.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Household.java new file mode 100644 index 00000000000..f29bde287ca --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Household.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.Text; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.NewMemberDto; +import com.codenameone.examples.pulse.api.PassDto; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.MembershipPlan; +import com.codenameone.examples.pulse.domain.PassKey; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipPlanRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.PassKeyRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// A household: the account holder and the children they book for. +/// +/// Every member of a household is booked, billed and let in on their own -- +/// a child has their own membership, their own place in a class and their +/// own door pass -- but only the account holder signs in. A child's age is +/// what decides which classes they can take, so it is read from their birth +/// date on the day of the class, never stored. +@Component +public class Household { + /// The most people one account books for. + static final int MOST = 8; + /// A door pass's code changes this often, in seconds. + public static final int PERIOD_SECONDS = 30; + /// And has this many digits. + public static final int DIGITS = 8; + + /// The states of a membership that still describe someone. + static final List CURRENT = Arrays.asList("ACTIVE", "FROZEN", "PAST_DUE"); + + private final MemberRepository members; + private final MembershipRepository memberships; + private final MembershipPlanRepository plans; + private final PassKeyRepository passes; + private final Accounts accounts; + + public Household(MemberRepository members, MembershipRepository memberships, + MembershipPlanRepository plans, PassKeyRepository passes, Accounts accounts) { + this.members = members; + this.memberships = memberships; + this.plans = plans; + this.passes = passes; + this.accounts = accounts; + } + + @Transactional(readOnly = true) + public List list(String account) throws IOException { + List rows = members.findByAccountOrderByCreatedAtAsc(account); + List out = new ArrayList(); + LocalDate today = LocalDate.now(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter), today)); + } + return out; + } + + /// Adds a child. Answers 400 for one who is old enough for an account of + /// their own, and 409 once the household is full. + @Transactional(rollbackFor = IOException.class) + public MemberDto addChild(String account, NewMemberDto child) throws IOException { + if (child == null) { + throw new ResponseStatusException(400, "Nothing to add"); + } + String name = Text.required(child.name(), 120, "their name"); + LocalDate born = Accounts.birthDate(child.birthDate()); + LocalDate today = LocalDate.now(); + if (Accounts.age(born, today) >= Accounts.ACCOUNT_AGE) { + throw new ResponseStatusException(400, "At " + Accounts.ACCOUNT_AGE + + " they can open an account of their own"); + } + String note = Text.optional(child.medicalNote(), 500, "the medical note"); + // Held, so two additions at once cannot both see room for one more. + accounts.hold(account); + if (members.countByAccount(account) >= MOST) { + throw new ResponseStatusException(409, "A household has at most " + MOST + + " members"); + } + Member row = new Member(); + row.setId(Ids.next()); + row.setAccount(account); + row.setName(name); + row.setBirthDate(born); + row.setRelation("CHILD"); + row.setMedicalNote(note); + row.setCreatedAt(Instant.now()); + members.insert(row); + return toDto(row, today); + } + + /// Records that the account holder signed the waiver for one of their + /// household. Signing again is harmless and keeps the first date. + @Transactional(rollbackFor = IOException.class) + public MemberDto signWaiver(String account, String memberId) throws IOException { + Member row = mine(account, memberId); + if (!signed(row)) { + row.setWaiverAt(Instant.now()); + } + return toDto(row, LocalDate.now()); + } + + /// A member's door pass, made the first time it is asked for. + @Transactional(rollbackFor = IOException.class) + public PassDto pass(String account, String memberId) throws IOException { + Member row = mine(account, memberId); + PassKey key = passes.findById(row.getId()); + if (key == null) { + key = new PassKey(); + key.setMemberId(row.getId()); + fill(key); + passes.insert(key); + } + return toDto(row, key); + } + + /// Replaces a pass's secret, for a phone that was lost: the old one's + /// codes and its NFC id stop opening the door at once. + @Transactional(rollbackFor = IOException.class) + public PassDto rotate(String account, String memberId) throws IOException { + Member row = mine(account, memberId); + PassKey key = passes.findById(row.getId()); + if (key == null) { + return pass(account, memberId); + } + fill(key); + return toDto(row, key); + } + + /// A member of the caller's household, or 404. Someone else's member is + /// answered the same as none, so ids cannot be probed. + public Member mine(String account, String memberId) { + Member row = memberId == null ? null : members.findFirstByIdAndAccount(memberId, account); + if (row == null) { + throw new ResponseStatusException(404, "No such member"); + } + return row; + } + + /// Whether a plan is a number of classes rather than a period of time: + /// a pack, or a single drop-in, which is a pack of one. + public static boolean counted(MembershipPlan plan) { + return "CLASS_PACK".equals(plan.getKind()) || "DROP_IN".equals(plan.getKind()); + } + + public static boolean signed(Member row) { + return row.getWaiverAt().isAfter(Instant.EPOCH); + } + + private static void fill(PassKey key) throws IOException { + key.setSecret(Ids.hex(Crypto.randomBytes(20))); + key.setNfcId(Ids.hex(Crypto.randomBytes(8))); + key.setCreatedAt(Instant.now()); + } + + private static PassDto toDto(Member row, PassKey key) { + return new PassDto(row.getId(), row.getName(), key.getSecret(), key.getNfcId(), + PERIOD_SECONDS, DIGITS); + } + + /// The member as the app shows them, with the membership that lets them + /// in, if any. + public MemberDto toDto(Member row, LocalDate today) { + Membership current = current(row.getId()); + MembershipPlan plan = current == null ? null : plans.findById(current.getPlanId()); + return new MemberDto(row.getId(), row.getName(), row.getBirthDate().toString(), + Accounts.age(row.getBirthDate(), today), row.getRelation(), signed(row), + row.getMedicalNote(), current == null ? "NONE" : current.getState(), + plan == null ? "" : plan.getName()); + } + + /// The membership a member holds now, or null. + public Membership current(String memberId) { + List held = memberships.findByMemberIdAndStateIn(memberId, CURRENT); + Membership best = null; + for (int iter = 0; iter < held.size(); iter++) { + Membership one = held.get(iter); + if (best == null || one.getStartedAt().isAfter(best.getStartedAt())) { + best = one; + } + } + return best; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/HouseholdEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/HouseholdEndpoint.java new file mode 100644 index 00000000000..78ab349f524 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/HouseholdEndpoint.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.HouseholdApiServer; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.NewMemberDto; +import com.codenameone.examples.pulse.api.PassDto; + +import java.util.List; + +/// The server's half of `HouseholdApi`: always the caller's own household. +@Component +public class HouseholdEndpoint implements HouseholdApiServer { + private final Household household; + + public HouseholdEndpoint(Household household) { + this.household = household; + } + + @Override + public List members() throws Exception { + return household.list(Caller.name()); + } + + @Override + public MemberDto addChild(NewMemberDto child) throws Exception { + return household.addChild(Caller.name(), child); + } + + @Override + public MemberDto signWaiver(String id) throws Exception { + return household.signWaiver(Caller.name(), id); + } + + @Override + public PassDto pass(String id) throws Exception { + return household.pass(Caller.name(), id); + } + + @Override + public PassDto rotatePass(String id) throws Exception { + return household.rotate(Caller.name(), id); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Passes.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Passes.java new file mode 100644 index 00000000000..8e0c31674ef --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Passes.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.security.Hash; +import com.codename1.security.Otp; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.domain.PassKey; +import com.codenameone.examples.pulse.domain.UsedPassCode; +import com.codenameone.examples.pulse.repository.PassKeyRepository; +import com.codenameone.examples.pulse.repository.UsedPassCodeRepository; + +import java.io.IOException; +import java.time.Instant; + +/// Reading a door pass. +/// +/// A pass on a phone is a QR code that changes every +/// [Household#PERIOD_SECONDS]: `pulse::`, where the code is +/// an HOTP (RFC 4226, SHA-256, [Household#DIGITS] digits) of the pass's +/// secret over the number of periods since the epoch -- the same +/// `com.codename1.security.Otp` call on the phone and here, so the phone +/// needs no signal to show a pass that works. A screenshot of one is good +/// for at most a period either side of now, and then only once: every code +/// accepted is kept until it could no longer be valid, and a second +/// presentation is refused as a replay. +/// +/// An NFC id is the other kind of pass: a fob or wristband, for a child +/// without a phone. It does not change, so it is as good as the thing it is +/// written on, and replacing the pass's secret replaces it too. +@Component +public class Passes { + /// What a QR pass starts with. + public static final String PREFIX = "pulse:"; + /// How many periods either side of now a code is still taken, for a + /// phone whose clock is a little off. + static final int TOLERANCE = 1; + + private final PassKeyRepository keys; + private final UsedPassCodeRepository used; + + public Passes(PassKeyRepository keys, UsedPassCodeRepository used) { + this.keys = keys; + this.used = used; + } + + /// What [#redeem] answers for a code that was right but has been shown + /// before. No member id is empty, so it cannot be mistaken for one. + public static final String REPLAYED = ""; + + /// The member a QR pass is for; [#REPLAYED] when it was shown before; or + /// null when it is not a pass or not current. Must run inside the caller's + /// transaction: the code is spent by inserting it, and a second insert of + /// the same code fails the transaction it is in. + public String redeem(String scanned, long nowMillis) throws IOException { + if (scanned == null || !scanned.startsWith(PREFIX)) { + return null; + } + int split = scanned.indexOf(':', PREFIX.length()); + if (split < 0) { + return null; + } + String memberId = scanned.substring(PREFIX.length(), split); + String code = scanned.substring(split + 1).trim(); + if (memberId.length() == 0 || memberId.length() > 32 + || code.length() != Household.DIGITS) { + return null; + } + PassKey key = keys.findById(memberId); + if (key == null) { + return null; + } + long counter = counter(nowMillis); + byte[] secret = bytes(key.getSecret()); + for (long step = counter - TOLERANCE; step <= counter + TOLERANCE; step++) { + if (same(code(secret, step), code)) { + String spent = Ids.sha256(memberId + ":" + key.getSecret() + ":" + step); + if (used.existsById(spent)) { + return REPLAYED; + } + UsedPassCode row = new UsedPassCode(); + row.setCodeHash(spent); + row.setExpiresAt(Instant.ofEpochMilli((step + TOLERANCE + 1) + * Household.PERIOD_SECONDS * 1000L)); + used.insert(row); + return memberId; + } + } + return null; + } + + /// The member a fob is for, or null. + public String byNfc(String nfcId) { + if (nfcId == null || nfcId.length() == 0 || nfcId.length() > 64) { + return null; + } + PassKey key = keys.findFirstByNfcId(nfcId); + return key == null ? null : key.getMemberId(); + } + + /// The code a pass shows at a moment, for the tests and the demo kiosk. + public static String code(String secretHex, long nowMillis) { + return code(bytes(secretHex), counter(nowMillis)); + } + + static String code(byte[] secret, long counter) { + return Otp.hotp(secret, counter, Household.DIGITS, Hash.SHA256); + } + + static long counter(long nowMillis) { + return nowMillis / 1000L / Household.PERIOD_SECONDS; + } + + /// Forgets the codes that could no longer be accepted. + @Scheduled(fixedDelay = 300000, initialDelay = 60000, lock = "pulse-pass-codes") + @Transactional + public void sweep() { + used.deleteByExpiresAtLessThan(Instant.now()); + } + + private static boolean same(String a, String b) { + if (a.length() != b.length()) { + return false; + } + int diff = 0; + for (int iter = 0; iter < a.length(); iter++) { + diff |= a.charAt(iter) ^ b.charAt(iter); + } + return diff == 0; + } + + private static byte[] bytes(String hex) { + byte[] out = new byte[hex.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) ((Character.digit(hex.charAt(iter * 2), 16) << 4) + | Character.digit(hex.charAt(iter * 2 + 1), 16)); + } + return out; + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Studio.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Studio.java new file mode 100644 index 00000000000..8083c7dbf7c --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/Studio.java @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Days; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.api.LocationDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.TrainerDto; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Friend; +import com.codenameone.examples.pulse.domain.Location; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Room; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.FriendRepository; +import com.codenameone.examples.pulse.repository.LocationRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// The timetable as everyone sees it: the locations, the kinds of class, the +/// trainers, and the classes themselves with how full each one is. +/// +/// A class as answered carries what only the caller sees beside what everyone +/// does: which of their household are booked into it or waiting for a place, +/// and how many of their friends are going. That is what makes a timetable +/// worth opening. +@Component +public class Studio { + /// A class that will run. + public static final String SCHEDULED = "SCHEDULED"; + /// A class the studio called off. Its places were handed back. + public static final String CANCELLED = "CANCELLED"; + /// A class that has ended, whose bookings were settled. + public static final String DONE = "DONE"; + /// The most days of timetable one request answers. + static final int MOST_DAYS = 28; + + private final LocationRepository locations; + private final RoomRepository rooms; + private final ClassTypeRepository types; + private final ClassSessionRepository sessions; + private final StaffRepository staff; + private final ProfileRepository profiles; + private final BookingRepository bookings; + private final FriendRepository friends; + + public Studio(LocationRepository locations, RoomRepository rooms, ClassTypeRepository types, + ClassSessionRepository sessions, StaffRepository staff, ProfileRepository profiles, + BookingRepository bookings, FriendRepository friends) { + this.locations = locations; + this.rooms = rooms; + this.types = types; + this.sessions = sessions; + this.staff = staff; + this.profiles = profiles; + this.bookings = bookings; + this.friends = friends; + } + + @Transactional(readOnly = true) + public List locations() { + List rows = locations.findAllByOrderByNameAsc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Location row = rows.get(iter); + out.add(new LocationDto(row.getId(), row.getName(), row.getAddress(), + row.getTimeZone(), row.getOpensMinute(), row.getClosesMinute())); + } + return out; + } + + /// The kinds of class; the retired ones only when `all`, for the owner. + @Transactional(readOnly = true) + public List types(boolean all) { + List rows = types.findAllByOrderByNameAsc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ClassType row = rows.get(iter); + if (all || row.isActive()) { + out.add(type(row)); + } + } + return out; + } + + public static ClassTypeDto type(ClassType row) { + return new ClassTypeDto(row.getId(), row.getName(), row.getDescription(), + row.getCategory(), row.getIntensity(), row.getMinAge(), row.getMaxAge(), + row.getCapacity(), row.getMinutes(), row.getColor(), row.isActive()); + } + + @Transactional(readOnly = true) + public List trainers() { + List rows = staff.findByActiveTrueOrderByUsernameAsc(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Staff row = rows.get(iter); + out.add(new TrainerDto(row.getUsername(), name(row.getUsername(), null), + row.getTitle(), row.getBio(), row.getLocationId())); + } + return out; + } + + /// The classes at a location -- or every location, for an empty one -- + /// from the start of the day `from` falls in, for `days` days. + @Transactional(readOnly = true) + public List classes(String viewer, String locationId, long from, int days) + throws IOException { + if (days < 1 || days > MOST_DAYS) { + throw new ResponseStatusException(400, "Ask for 1 to " + MOST_DAYS + " days"); + } + // The caller's own start, as given: the app sends its local midnight, + // and rounding it to a UTC day would pull in yesterday's classes for + // a studio east of Greenwich. + Instant start = Instant.ofEpochMilli(from <= 0L + ? Days.start(System.currentTimeMillis()) : from); + Instant end = start.plusMillis(days * Days.MILLIS); + List rows = locationId == null || locationId.length() == 0 + ? sessions.findByStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + start, end) + : sessions.findByLocationIdAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + locationId, start, end); + return describe(viewer, rows); + } + + /// One class. Answers 404 for an id that is none. + @Transactional(readOnly = true) + public SessionDto get(String viewer, String id) throws IOException { + ClassSession row = session(id); + List one = new ArrayList(); + one.add(row); + return describe(viewer, one).get(0); + } + + /// A class, or null. + public ClassSession sessionOrNull(String id) { + return id == null || id.length() == 0 || id.length() > 32 ? null : sessions.findById(id); + } + + /// The name of the kind of class `session` is, or null. + public String className(ClassSession session) { + ClassType type = session == null ? null : types.findById(session.getClassTypeId()); + return type == null ? null : type.getName(); + } + + /// A class, or 404. + public ClassSession session(String id) { + ClassSession row = sessionOrNull(id); + if (row == null) { + throw new ResponseStatusException(404, "No such class"); + } + return row; + } + + /// Classes as `viewer` sees them. Everything the rows name -- types, + /// rooms, trainers, the viewer's bookings and their friends' -- is read + /// once for the whole list, not once a class. + public List describe(String viewer, List rows) throws IOException { + List out = new ArrayList(); + if (rows.isEmpty()) { + return out; + } + Map typeById = new HashMap(); + List allTypes = types.findAllByOrderByNameAsc(); + for (int iter = 0; iter < allTypes.size(); iter++) { + typeById.put(allTypes.get(iter).getId(), allTypes.get(iter)); + } + Map roomNames = new HashMap(); + List allRooms = rooms.findAllByOrderByLocationIdAscNameAsc(); + for (int iter = 0; iter < allRooms.size(); iter++) { + roomNames.put(allRooms.get(iter).getId(), allRooms.get(iter).getName()); + } + Map locationNames = new HashMap(); + List allLocations = locations.findAllByOrderByNameAsc(); + for (int iter = 0; iter < allLocations.size(); iter++) { + locationNames.put(allLocations.get(iter).getId(), allLocations.get(iter).getName()); + } + List ids = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ids.add(rows.get(iter).getId()); + } + Set circle = viewer == null ? new HashSet() : circle(viewer); + List taken = viewer == null ? new ArrayList() + : bookings.findBySessionIdIn(ids); + Map trainerNames = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + ClassSession row = rows.get(iter); + ClassType type = typeById.get(row.getClassTypeId()); + List mine = new ArrayList(); + List queued = new ArrayList(); + Set going = new HashSet(); + for (int b = 0; b < taken.size(); b++) { + Booking booking = taken.get(b); + if (!booking.getSessionId().equals(row.getId())) { + continue; + } + boolean held = Bookings.BOOKED.equals(booking.getState()) + || Bookings.CHECKED_IN.equals(booking.getState()); + if (booking.getAccount().equals(viewer)) { + if (held) { + mine.add(booking.getMemberId()); + } else if (Bookings.WAITLISTED.equals(booking.getState())) { + queued.add(booking.getMemberId()); + } + } else if (held && circle.contains(booking.getAccount())) { + going.add(booking.getAccount()); + } + } + out.add(new SessionDto(row.getId(), row.getClassTypeId(), + type == null ? "" : type.getName(), type == null ? "" : type.getCategory(), + type == null ? 0 : type.getIntensity(), type == null ? 0 : type.getMinAge(), + type == null ? 0 : type.getMaxAge(), type == null ? "" : type.getColor(), + row.getLocationId(), text(locationNames.get(row.getLocationId())), + text(roomNames.get(row.getRoomId())), row.getTrainer(), + name(row.getTrainer(), trainerNames), row.getStartsAt().toEpochMilli(), + row.getEndsAt().toEpochMilli(), row.getCapacity(), row.getBooked(), + row.getWaitlisted(), row.getState(), row.getNote(), mine, queued, + going.size())); + } + return out; + } + + /// The accounts `viewer` is friends with: both sides accepted. + Set circle(String viewer) { + Set out = new HashSet(); + List rows = friends.findByAccountAndState(viewer, "ACCEPTED"); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(rows.get(iter).getFriend()); + } + return out; + } + + /// An account's display name, through `cache` when there is one. + String name(String username, Map cache) { + if (username == null || username.length() == 0) { + return ""; + } + String known = cache == null ? null : cache.get(username); + if (known != null) { + return known; + } + Profile profile = profiles.findById(username); + String name = profile == null ? "" : profile.getDisplayName(); + if (cache != null) { + cache.put(username, name); + } + return name; + } + + private static String text(String value) { + return value == null ? "" : value; + } + + /// The time zone the studio as a whole keeps its weeks in, for figures + /// that add up every location: the zone of the location first by id. + public ZoneId zone() { + List all = locations.findAll(); + String first = null; + for (int iter = 0; iter < all.size(); iter++) { + String id = all.get(iter).getId(); + if (first == null || id.compareTo(first) < 0) { + first = id; + } + } + return zone(first); + } + + /// The Monday that starts the week `day` is in. The compiled backend's + /// LocalDate has no with(TemporalAdjuster), so it counts back. + public static LocalDate monday(LocalDate day) { + return day.minusDays(day.getDayOfWeek().getValue() - 1); + } + + /// The time zone a location keeps its days in: UTC for a location that + /// names none, or one this runtime does not know. + public ZoneId zone(String locationId) { + Location location = locationId == null ? null : locations.findById(locationId); + String id = location == null ? "" : location.getTimeZone(); + try { + return ZoneId.of(id == null || id.length() == 0 ? "UTC" : id); + } catch (RuntimeException unknown) { + return ZoneId.of("UTC"); + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/StudioEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/StudioEndpoint.java new file mode 100644 index 00000000000..56616bb4bce --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/studio/StudioEndpoint.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.studio; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.api.BookRequestDto; +import com.codenameone.examples.pulse.api.BookingApiServer; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.api.LocationDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.StudioApiServer; +import com.codenameone.examples.pulse.api.TrainerDto; + +import java.util.List; + +/// The server's half of `StudioApi` and `BookingApi`: the timetable, and the +/// caller's own household's places in it. +@Component +public class StudioEndpoint implements StudioApiServer, BookingApiServer { + private final Studio studio; + private final Bookings bookings; + + public StudioEndpoint(Studio studio, Bookings bookings) { + this.studio = studio; + this.bookings = bookings; + } + + @Override + public List locations() throws Exception { + Caller.name(); + return studio.locations(); + } + + @Override + public List classTypes() throws Exception { + Caller.name(); + return studio.types(false); + } + + @Override + public List trainers() throws Exception { + Caller.name(); + return studio.trainers(); + } + + @Override + public List classes(String location, long from, int days) throws Exception { + return studio.classes(Caller.name(), location, from, days <= 0 ? 7 : days); + } + + @Override + public SessionDto session(String id) throws Exception { + return studio.get(Caller.name(), id); + } + + @Override + public List upcoming() throws Exception { + return bookings.upcoming(Caller.name()); + } + + @Override + public List history() throws Exception { + return bookings.history(Caller.name()); + } + + @Override + public BookingDto book(BookRequestDto request) throws Exception { + return bookings.book(Caller.name(), request); + } + + @Override + public BookingDto cancel(String id) throws Exception { + return bookings.cancel(Caller.name(), id); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/HeartRates.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/HeartRates.java new file mode 100644 index 00000000000..4485f8d0371 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/HeartRates.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.backend.annotations.Component; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/// The latest heart rate of each member in each class under way, as their +/// watches send it. +/// +/// Kept in memory and nowhere else: a reading is worth something for a few +/// seconds, to the trainer looking at the roster, and the member's workout +/// -- what is kept -- arrives whole when it ends. A restart loses only the +/// readings since the last one each watch sent. +@Component +public class HeartRates { + /// A reading older than this is not shown. + static final long FRESH_MILLIS = 60000L; + + private final Map latest = new HashMap(); + private final Map at = new HashMap(); + + /// The zone, 1 to 5, a heart rate is in for someone of `age`: the share + /// of an estimated maximum of 220 less their age, in fifths from 50%. + public static int zone(int bpm, int age) { + int max = Math.max(120, 220 - age); + int percent = bpm * 100 / max; + if (percent < 60) { + return 1; + } + if (percent < 70) { + return 2; + } + if (percent < 80) { + return 3; + } + return percent < 90 ? 4 : 5; + } + + public synchronized void put(String sessionId, String memberId, int bpm, int zone, + long now) { + String key = sessionId + "/" + memberId; + latest.put(key, new int[] {bpm, zone}); + at.put(key, Long.valueOf(now)); + if (latest.size() > 5000) { + forgetStale(now); + } + } + + /// `{bpm, zone}`, or null when there is no fresh reading. + public synchronized int[] get(String sessionId, String memberId, long now) { + String key = sessionId + "/" + memberId; + Long when = at.get(key); + if (when == null || now - when.longValue() > FRESH_MILLIS) { + return null; + } + return latest.get(key); + } + + private void forgetStale(long now) { + Iterator> it = at.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (now - entry.getValue().longValue() > FRESH_MILLIS) { + latest.remove(entry.getKey()); + it.remove(); + } + } + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/TrainerEndpoint.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/TrainerEndpoint.java new file mode 100644 index 00000000000..df78ddecd13 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/TrainerEndpoint.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.pulse.Caller; +import com.codenameone.examples.pulse.Roles; +import com.codenameone.examples.pulse.api.AttendanceDto; +import com.codenameone.examples.pulse.api.RosterEntryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.api.TrainerStatsDto; +import com.codenameone.examples.pulse.api.TrainerApiServer; + +import java.util.List; + +/// The server's half of `TrainerApi`. An owner may read any class's roster +/// and take its attendance; a trainer only their own. +@Component +@PreAuthorize("hasAnyRole('TRAINER', 'OWNER')") +public class TrainerEndpoint implements TrainerApiServer { + private final Trainers trainers; + + public TrainerEndpoint(Trainers trainers) { + this.trainers = trainers; + } + + @Override + public List classes() throws Exception { + return trainers.classes(Caller.name()); + } + + @Override + public List roster(String id) throws Exception { + return trainers.roster(Caller.name(), Caller.hasRole(Roles.OWNER), id); + } + + @Override + public RosterEntryDto attendance(String id, AttendanceDto mark) throws Exception { + return trainers.attendance(Caller.name(), Caller.hasRole(Roles.OWNER), id, mark); + } + + @Override + public TrainerStatsDto stats() throws Exception { + return trainers.stats(Caller.name()); + } + + @Override + public List shifts() throws Exception { + return trainers.shifts(Caller.name()); + } + + @Override + public ShiftDto offer(String id) throws Exception { + return trainers.offer(Caller.name(), id); + } + + @Override + public ShiftDto take(String id) throws Exception { + return trainers.take(Caller.name(), id); + } +} diff --git a/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/Trainers.java b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/Trainers.java new file mode 100644 index 00000000000..9bfa0f25459 --- /dev/null +++ b/scripts/pulse/backend/src/main/java/com/codenameone/examples/pulse/trainer/Trainers.java @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.pulse.Ids; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.AttendanceDto; +import com.codenameone.examples.pulse.api.CoachStatDto; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.RosterEntryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.api.TrainerStatsDto; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.CheckIn; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.ClassType; +import com.codenameone.examples.pulse.domain.Kudos; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Profile; +import com.codenameone.examples.pulse.domain.Shift; +import com.codenameone.examples.pulse.domain.Staff; +import com.codenameone.examples.pulse.domain.Workout; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.CheckInRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.ClassTypeRepository; +import com.codenameone.examples.pulse.repository.KudosRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.ProfileRepository; +import com.codenameone.examples.pulse.repository.ShiftRepository; +import com.codenameone.examples.pulse.repository.StaffRepository; +import com.codenameone.examples.pulse.repository.WorkoutRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Studio; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A trainer's day: the classes they teach, who is coming, who came, and +/// the shifts they work. +@Component +public class Trainers { + /// The shift states. + public static final String ASSIGNED = "ASSIGNED"; + public static final String SWAP_REQUESTED = "SWAP_REQUESTED"; + /// Attendance can be corrected this long after a class ends, before the + /// class is settled and its absentees become no-shows. + static final long CORRECT_MILLIS = 30L * 60000L; + + /// What a coach earns. A full class is worth a lot because it is the + /// thing a studio needs most from a coach. + static final int POINTS_CHECK_IN = 10; + static final int POINTS_KUDOS = 5; + static final int POINTS_FULL_HOUSE = 25; + static final int LEVEL_POINTS = 400; + + private final ClassSessionRepository sessions; + private final BookingRepository bookings; + private final MemberRepository members; + private final CheckInRepository checkIns; + private final ShiftRepository shifts; + private final ProfileRepository profiles; + private final Studio studio; + private final HeartRates rates; + private final ClassTypeRepository types; + private final WorkoutRepository workouts; + private final KudosRepository kudos; + private final StaffRepository staff; + + public Trainers(ClassSessionRepository sessions, BookingRepository bookings, + MemberRepository members, CheckInRepository checkIns, ShiftRepository shifts, + ProfileRepository profiles, Studio studio, HeartRates rates, ClassTypeRepository types, + WorkoutRepository workouts, KudosRepository kudos, StaffRepository staff) { + this.staff = staff; + this.types = types; + this.workouts = workouts; + this.kudos = kudos; + this.sessions = sessions; + this.bookings = bookings; + this.members = members; + this.checkIns = checkIns; + this.shifts = shifts; + this.profiles = profiles; + this.studio = studio; + this.rates = rates; + } + + /// The classes `trainer` teaches from the start of today for a week. + @Transactional(readOnly = true) + public List classes(String trainer) throws IOException { + Instant from = Instant.ofEpochMilli(System.currentTimeMillis() / 86400000L * 86400000L); + return studio.describe(trainer, + sessions.findByTrainerAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + trainer, from, from.plusMillis(7L * 86400000L))); + } + + /// Who is booked into a class, who is waiting, who came; with what the + /// trainer needs to know about each, and their heart rate when it is + /// being shared. + @Transactional(readOnly = true) + public List roster(String caller, boolean owner, String sessionId) { + ClassSession session = teaching(caller, owner, sessionId); + List rows = bookings.findBySessionIdOrderByCreatedAtAsc(session.getId()); + List out = new ArrayList(); + LocalDate day = LocalDate.ofEpochDay(session.getStartsAt().toEpochMilli() / 86400000L); + long now = System.currentTimeMillis(); + for (int iter = 0; iter < rows.size(); iter++) { + Booking row = rows.get(iter); + if (Bookings.CANCELLED.equals(row.getState()) + || Bookings.LATE_CANCEL.equals(row.getState())) { + continue; + } + Member member = members.findById(row.getMemberId()); + if (member == null) { + continue; + } + out.add(entry(row, member, day, session.getId(), now)); + } + return out; + } + + private RosterEntryDto entry(Booking row, Member member, LocalDate day, String sessionId, + long now) { + long before = bookings.countByMemberIdAndState(member.getId(), Bookings.CHECKED_IN); + boolean first = before == 0 || before == 1 && Bookings.CHECKED_IN.equals(row.getState()); + int[] rate = rates.get(sessionId, member.getId(), now); + return new RosterEntryDto(row.getId(), member.getId(), member.getName(), + Accounts.age(member.getBirthDate(), day), row.getState(), + (int) row.getQueuePosition(), first, member.getMedicalNote(), + rate == null ? 0 : rate[0], rate == null ? 0 : rate[1]); + } + + /// Marks someone present or absent, for a member the door did not see: + /// a child brought in through the side, a phone that was flat. + @Transactional(rollbackFor = IOException.class) + public RosterEntryDto attendance(String caller, boolean owner, String sessionId, + AttendanceDto mark) throws IOException { + if (mark == null || mark.bookingId() == null) { + throw new ResponseStatusException(400, "Say who"); + } + ClassSession session = teaching(caller, owner, sessionId); + long now = System.currentTimeMillis(); + if (now < session.getStartsAt().toEpochMilli() - 3600000L + || now > session.getEndsAt().toEpochMilli() + CORRECT_MILLIS + || !Studio.SCHEDULED.equals(session.getState())) { + throw new ResponseStatusException(409, + "Attendance is taken from an hour before a class to just after it"); + } + Booking row = bookings.findById(mark.bookingId()); + if (row == null || !row.getSessionId().equals(session.getId())) { + throw new ResponseStatusException(404, "No such booking"); + } + boolean here = Bookings.CHECKED_IN.equals(row.getState()); + if (mark.present() && Bookings.BOOKED.equals(row.getState())) { + row.setState(Bookings.CHECKED_IN); + row.setCheckedInAt(Instant.ofEpochMilli(now)); + CheckIn visit = new CheckIn(); + visit.setId(Ids.next()); + visit.setMemberId(row.getMemberId()); + visit.setAccount(row.getAccount()); + visit.setLocationId(session.getLocationId()); + visit.setMethod("STAFF"); + visit.setSessionId(session.getId()); + visit.setAt(Instant.ofEpochMilli(now)); + visit.setStaff(caller); + checkIns.insert(visit); + } else if (!mark.present() && here) { + // A mistaken tick. The visit it recorded stays in the door's + // log; the booking goes back to expected. + row.setState(Bookings.BOOKED); + row.setCheckedInAt(Instant.EPOCH); + } else if (!Bookings.BOOKED.equals(row.getState()) && !here) { + throw new ResponseStatusException(409, "That person is not booked into the class"); + } + row.setUpdatedAt(Instant.ofEpochMilli(now)); + Member member = members.findById(row.getMemberId()); + LocalDate day = LocalDate.ofEpochDay(session.getStartsAt().toEpochMilli() / 86400000L); + return entry(row, member, day, session.getId(), now); + } + + /// A class the caller teaches, or any class for the owner. Someone + /// else's is answered as none. + ClassSession teaching(String caller, boolean owner, String sessionId) { + ClassSession session = studio.sessionOrNull(sessionId); + if (session == null || !owner && !caller.equals(session.getTrainer())) { + throw new ResponseStatusException(404, "No such class"); + } + return session; + } + + /// The trainer's shifts still to come, and the ones others offered up. + @Transactional(readOnly = true) + public List shifts(String trainer) { + Instant now = Instant.now(); + List mine = shifts.findByStaffAndEndsAtGreaterThanOrderByStartsAtAsc(trainer, + now); + List out = new ArrayList(); + String home = ""; + for (int iter = 0; iter < mine.size(); iter++) { + out.add(dto(mine.get(iter))); + home = mine.get(iter).getLocationId(); + } + if (home.length() > 0) { + List offered = shifts.findByLocationIdAndStateAndEndsAtGreaterThanOrderByStartsAtAsc( + home, SWAP_REQUESTED, now); + for (int iter = 0; iter < offered.size(); iter++) { + if (!offered.get(iter).getStaff().equals(trainer)) { + out.add(dto(offered.get(iter))); + } + } + } + return out; + } + + /// Offers one of the caller's shifts to the others. Offering it again + /// withdraws the offer. + @Transactional(rollbackFor = IOException.class) + public ShiftDto offer(String trainer, String id) { + Shift row = id == null ? null : shifts.findById(id); + if (row == null || !row.getStaff().equals(trainer)) { + throw new ResponseStatusException(404, "No such shift"); + } + if (!row.getStartsAt().isAfter(Instant.now())) { + throw new ResponseStatusException(409, "That shift has started"); + } + row.setState(SWAP_REQUESTED.equals(row.getState()) ? ASSIGNED : SWAP_REQUESTED); + return dto(row); + } + + /// Takes a shift someone else offered. Two trainers tapping Take together + /// are told apart by one conditional statement ([ShiftRepository#take]). + @Transactional(rollbackFor = IOException.class) + public ShiftDto take(String trainer, String id) { + Shift row = id == null ? null : shifts.findById(id); + if (row == null) { + throw new ResponseStatusException(404, "No such shift"); + } + if (!row.getStartsAt().isAfter(Instant.now())) { + throw new ResponseStatusException(409, "That shift has started"); + } + if (shifts.take(id, trainer) != 1) { + throw new ResponseStatusException(409, "Somebody else took that shift"); + } + return dto(shifts.findById(id)); + } + + public ShiftDto dto(Shift row) { + Profile who = profiles.findById(row.getStaff()); + return new ShiftDto(row.getId(), row.getLocationId(), row.getStaff(), + who == null ? "" : who.getDisplayName(), row.getDuty(), + row.getStartsAt().toEpochMilli(), row.getEndsAt().toEpochMilli(), row.getState()); + } + + // ----------------------------------------------------------------- stats + + /// What a trainer's classes added up to: the last four weeks, and the + /// eight weeks of attendance behind them. + private static final class Tally { + int classes; + int attendees; + long booked; + long offered; + int missed; + int kudos; + int regulars; + int fullHouses; + int[] byWeek = new int[8]; + /// Classes taught, a week at a time, as `byWeek` counts people. + int[] taughtByWeek = new int[8]; + Map byType = new HashMap(); + + int fillPercent() { + return offered == 0 ? 0 : (int) Math.min(100L, booked * 100 / offered); + } + + int showPercent() { + return attendees + missed == 0 ? 0 : attendees * 100 / (attendees + missed); + } + } + + /// The signed-in trainer's last four weeks. + @Transactional(readOnly = true) + public TrainerStatsDto stats(String trainer) { + Tally tally = tally(trainer); + List weeks = new ArrayList(); + for (int iter = 0; iter < tally.byWeek.length; iter++) { + weeks.add(Integer.valueOf(tally.byWeek[iter])); + } + List top = new ArrayList(); + for (Map.Entry entry : tally.byType.entrySet()) { + ClassType type = types.findById(entry.getKey()); + top.add(new NameCountDto(type == null ? entry.getKey() : type.getName(), + entry.getValue()[0])); + } + top.sort((a, b) -> b.count() - a.count()); + + // The game: points from the eight weeks, so a coach who stops + // teaching drifts down rather than keeping a level for ever. + int seen = 0; + for (int iter = 0; iter < tally.byWeek.length; iter++) { + seen += tally.byWeek[iter]; + } + int points = seen * POINTS_CHECK_IN + tally.kudos * POINTS_KUDOS + + tally.fullHouses * POINTS_FULL_HOUSE; + int level = points / LEVEL_POINTS + 1; + int streak = 0; + // This week counts once it has a class; until then the streak runs + // to last week, so Monday morning does not break it. + int last = tally.byWeek.length - 1; + int from = tally.taughtByWeek[last] > 0 ? last : last - 1; + for (int iter = from; iter >= 0 && tally.taughtByWeek[iter] > 0; iter--) { + streak++; + } + List board = leaderboard(); + int rank = 0; + String name = displayName(trainer); + for (int iter = 0; iter < board.size(); iter++) { + if (board.get(iter).name().equals(name) && tally.classes > 0) { + rank = iter + 1; + break; + } + } + List badges = new ArrayList(); + earn(badges, "FULL_HOUSE", tally.fullHouses >= 1); + earn(badges, "FIVE_FULL", tally.fullHouses >= 5); + earn(badges, "TEN_REGULARS", tally.regulars >= 10); + earn(badges, "KUDOS_25", tally.kudos >= 25); + earn(badges, "SHOW_90", tally.attendees >= 20 && tally.showPercent() >= 90); + earn(badges, "STREAK_8", streak >= tally.byWeek.length); + earn(badges, "HUNDRED", seen >= 100); + earn(badges, "TOP_COACH", rank == 1); + return new TrainerStatsDto(tally.classes, tally.attendees, tally.fillPercent(), + tally.showPercent(), tally.regulars, tally.kudos, weeks, top, points, level, + (level - 1) * LEVEL_POINTS, level * LEVEL_POINTS, streak, tally.fullHouses, rank, + badges, board); + } + + private static void earn(List badges, String id, boolean earned) { + if (earned) { + badges.add(id); + } + } + + private String displayName(String trainer) { + Profile who = profiles.findById(trainer); + return who == null ? trainer : who.getDisplayName(); + } + + /// Every active coach who taught in the four weeks, by the people who + /// came to them, most first. A studio has a handful of coaches, so each + /// is tallied in turn, as the owner's insights do. + private List leaderboard() { + List board = new ArrayList(); + List team = staff.findByActiveTrueOrderByUsernameAsc(); + for (int iter = 0; iter < team.size(); iter++) { + String coach = team.get(iter).getUsername(); + Tally their = tally(coach); + if (their.classes > 0) { + board.add(new NameCountDto(displayName(coach), their.attendees)); + } + } + board.sort((a, b) -> b.count() - a.count()); + return board; + } + + /// The same four weeks as the owner's insights show them. + @Transactional(readOnly = true) + public CoachStatDto coach(String trainer) { + Tally tally = tally(trainer); + Profile who = profiles.findById(trainer); + return new CoachStatDto(trainer, who == null ? trainer : who.getDisplayName(), + tally.classes, tally.attendees, tally.fillPercent(), tally.showPercent(), + tally.kudos); + } + + /// Classes that are over and were not cancelled, from the Monday eight + /// weeks back in the studio's zone. The four-week figures count from four + /// weeks before now, as the owner's dashboard does. + private Tally tally(String trainer) { + Tally tally = new Tally(); + Instant now = Instant.now(); + ZoneId zone = studio.zone(); + LocalDate thisWeek = Studio.monday(LocalDate.now(zone)); + LocalDate firstWeek = thisWeek.minusWeeks(tally.byWeek.length - 1); + Instant from = firstWeek.atStartOfDay(zone).toInstant(); + Instant month = now.minusMillis(28L * 86400000L); + List taught = sessions.findByTrainerAndStartsAtGreaterThanEqualAndStartsAtLessThanOrderByStartsAtAsc( + trainer, from, now); + Map recent = new HashMap(); + List ids = new ArrayList(); + for (int iter = 0; iter < taught.size(); iter++) { + ClassSession row = taught.get(iter); + if (Studio.CANCELLED.equals(row.getState()) || row.getEndsAt().isAfter(now)) { + continue; + } + ids.add(row.getId()); + int taughtWeek = (int) ((row.getStartsAt().atZone(zone).toLocalDate().toEpochDay() + - firstWeek.toEpochDay()) / 7); + if (taughtWeek >= 0 && taughtWeek < tally.taughtByWeek.length) { + tally.taughtByWeek[taughtWeek]++; + } + if (!row.getStartsAt().isBefore(month)) { + if (row.getCapacity() > 0 && row.getBooked() >= row.getCapacity()) { + tally.fullHouses++; + } + recent.put(row.getId(), row); + tally.classes++; + tally.booked += row.getBooked(); + tally.offered += row.getCapacity(); + } + } + if (ids.isEmpty()) { + return tally; + } + Map byId = new HashMap(); + for (int iter = 0; iter < taught.size(); iter++) { + byId.put(taught.get(iter).getId(), taught.get(iter)); + } + Map visits = new HashMap(); + List rows = bookings.findBySessionIdIn(ids); + for (int iter = 0; iter < rows.size(); iter++) { + Booking row = rows.get(iter); + ClassSession session = byId.get(row.getSessionId()); + boolean came = Bookings.CHECKED_IN.equals(row.getState()); + if (came && session != null) { + LocalDate day = session.getStartsAt().atZone(zone).toLocalDate(); + int week = (int) ((day.toEpochDay() - firstWeek.toEpochDay()) / 7); + if (week >= 0 && week < tally.byWeek.length) { + tally.byWeek[week]++; + } + } + if (!recent.containsKey(row.getSessionId())) { + continue; + } + if (came) { + tally.attendees++; + count(visits, row.getMemberId()); + count(tally.byType, session.getClassTypeId()); + } else if (Bookings.NO_SHOW.equals(row.getState())) { + tally.missed++; + } + } + for (int[] count : visits.values()) { + if (count[0] >= 3) { + tally.regulars++; + } + } + if (!recent.isEmpty()) { + List recorded = workouts.findBySessionIdIn( + new ArrayList(recent.keySet())); + List workoutIds = new ArrayList(); + for (int iter = 0; iter < recorded.size(); iter++) { + workoutIds.add(recorded.get(iter).getId()); + } + if (!workoutIds.isEmpty()) { + List given = kudos.findByWorkoutIdIn(workoutIds); + tally.kudos = given.size(); + } + } + return tally; + } + + private static void count(Map counts, String key) { + int[] count = counts.get(key); + if (count == null) { + count = new int[1]; + counts.put(key, count); + } + count[0]++; + } +} diff --git a/scripts/pulse/backend/src/main/resources/db/migration/V2__accounts.sql b/scripts/pulse/backend/src/main/resources/db/migration/V2__accounts.sql new file mode 100644 index 00000000000..98c8063cf28 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/V2__accounts.sql @@ -0,0 +1,80 @@ +-- What the app knows about an account beyond its sign-in. The sign-in itself -- +-- the password hash, the roles, whether the account is enabled -- lives in the +-- security tables the backend creates (cn1.security.schema.enabled), keyed by +-- the same username. +-- +-- Every script here is written once for SQLite, PostgreSQL and MySQL: no +-- generated keys, no native boolean or timestamp. A yes/no is a SMALLINT 0/1 and +-- a time is milliseconds since the epoch in a BIGINT. +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(120) NOT NULL, + phone VARCHAR(32) NOT NULL, + phone_verified SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + language VARCHAR(8) NOT NULL, + flagged SMALLINT NOT NULL, + flag_reason VARCHAR(255) NOT NULL, + -- The member who sent this one, or empty. Both are credited when this + -- account pays its first invoice. + referred_by VARCHAR(190) NOT NULL +); + +-- How one person wants the app and its reminders. A row exists once they have +-- saved something; until then the defaults are the server's. +CREATE TABLE preference ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + theme VARCHAR(8) NOT NULL, + units VARCHAR(4) NOT NULL, + notify_classes SMALLINT NOT NULL, + notify_receipts SMALLINT NOT NULL, + notify_promotions SMALLINT NOT NULL, + -- Hours of the day, 0-23, between which no nudge is sent. Equal means + -- none: reminders of a booked class are always sent. + quiet_from INTEGER NOT NULL, + quiet_to INTEGER NOT NULL +); + +-- The e-mails of accounts that were closed, as hashes. A closed account's invoices +-- stay under its e-mail, so the address is never registered again; the hash is +-- enough to refuse it without keeping a list of who has left. +CREATE TABLE closed_account ( + username_hash VARCHAR(64) NOT NULL PRIMARY KEY, + closed_at BIGINT NOT NULL +); + +-- The verification code an account is waiting on. Only its hash is kept, and +-- that of the code before it while it is still good: text messages are not +-- delivered in the order they were sent. +CREATE TABLE phone_code ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + code_hash VARCHAR(64) NOT NULL, + expires_at BIGINT NOT NULL, + attempts INTEGER NOT NULL, + sent_at BIGINT NOT NULL, + previous_hash VARCHAR(64) NOT NULL DEFAULT '', + previous_expires_at BIGINT NOT NULL DEFAULT 0, + previous_attempts INTEGER NOT NULL DEFAULT 0 +); + +-- Tickets for opening the live channel. A row is deleted by the connection that +-- presents it, which is what makes a ticket good for one connection only -- and +-- keeping them here and not in memory is what lets a second server instance +-- redeem a ticket the first one issued. +CREATE TABLE live_ticket ( + ticket_hash VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + expires_at BIGINT NOT NULL +); + +-- What the owners did to an account and why: flagged it, blocked it, changed +-- its roles. Never changed once written. +CREATE TABLE moderation_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + actor VARCHAR(190) NOT NULL, + action VARCHAR(16) NOT NULL, + reason VARCHAR(255) NOT NULL +); +CREATE INDEX moderation_event_user ON moderation_event (username, created_at); diff --git a/scripts/pulse/backend/src/main/resources/db/migration/V3__studio.sql b/scripts/pulse/backend/src/main/resources/db/migration/V3__studio.sql new file mode 100644 index 00000000000..38b182b5945 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/V3__studio.sql @@ -0,0 +1,164 @@ +-- The studio: where classes happen, who teaches them, who comes to them. +-- +-- As in every script here: no generated keys, a yes/no is a SMALLINT 0/1, a +-- time is milliseconds since the epoch in a BIGINT and a date (a birthday, the +-- first day of a trip) is the day count since 1970-01-01 in a BIGINT. + +-- A site. Opening hours are minutes after midnight in the site's own zone. +CREATE TABLE location ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + address VARCHAR(255) NOT NULL, + time_zone VARCHAR(64) NOT NULL, + opens_minute INTEGER NOT NULL, + closes_minute INTEGER NOT NULL +); + +CREATE TABLE room ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + location_id VARCHAR(32) NOT NULL, + name VARCHAR(80) NOT NULL, + capacity INTEGER NOT NULL +); + +-- An account that works here. What it may do is its roles; this row is what +-- members are shown of a trainer, and when the certificate that lets them +-- teach runs out. +CREATE TABLE staff ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + location_id VARCHAR(32) NOT NULL, + title VARCHAR(80) NOT NULL, + bio VARCHAR(1000) NOT NULL, + certified_until BIGINT NOT NULL, + active SMALLINT NOT NULL +); + +-- A kind of class. Ages are inclusive, in whole years on the day of the +-- class; 0 and 120 mean no bound. +CREATE TABLE class_type ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(80) NOT NULL, + description VARCHAR(1000) NOT NULL, + category VARCHAR(24) NOT NULL, + intensity INTEGER NOT NULL, + min_age INTEGER NOT NULL, + max_age INTEGER NOT NULL, + capacity INTEGER NOT NULL, + minutes INTEGER NOT NULL, + color VARCHAR(8) NOT NULL, + active SMALLINT NOT NULL +); + +-- One class on the timetable. `booked` counts the places taken and is only +-- ever changed by a conditional update against `capacity`, which is what +-- makes overbooking impossible whatever arrives at once. +CREATE TABLE class_session ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + class_type_id VARCHAR(32) NOT NULL, + location_id VARCHAR(32) NOT NULL, + room_id VARCHAR(32) NOT NULL, + trainer VARCHAR(190) NOT NULL, + starts_at BIGINT NOT NULL, + ends_at BIGINT NOT NULL, + capacity INTEGER NOT NULL, + booked INTEGER NOT NULL, + waitlisted INTEGER NOT NULL, + state VARCHAR(16) NOT NULL, + note VARCHAR(255) NOT NULL +); +CREATE INDEX class_session_start ON class_session (location_id, starts_at); +CREATE INDEX class_session_trainer ON class_session (trainer, starts_at); + +-- A person who trains here. One account can hold several: the adult who signs +-- in is a member themselves (relation SELF), and books for their children +-- (relation CHILD), who have no account of their own. Not called `member`: +-- MySQL reserves the word. +CREATE TABLE studio_member ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + name VARCHAR(120) NOT NULL, + birth_date BIGINT NOT NULL, + relation VARCHAR(8) NOT NULL, + waiver_at BIGINT NOT NULL, + medical_note VARCHAR(500) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX studio_member_account ON studio_member (account); + +-- A place in a class, or in the queue for one. `queue_position` orders the +-- waitlist; it is 0 for a place. +CREATE TABLE booking ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + session_id VARCHAR(32) NOT NULL, + member_id VARCHAR(32) NOT NULL, + account VARCHAR(190) NOT NULL, + state VARCHAR(16) NOT NULL, + queue_position BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + checked_in_at BIGINT NOT NULL, + invited_by VARCHAR(190) NOT NULL +); +CREATE UNIQUE INDEX booking_once ON booking (session_id, member_id); +CREATE INDEX booking_account ON booking (account, created_at); + +-- Coming through the door. Every check-in is kept: it is what attendance, +-- streaks and the "we miss you" nudge are worked out from. +CREATE TABLE check_in ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + member_id VARCHAR(32) NOT NULL, + account VARCHAR(190) NOT NULL, + location_id VARCHAR(32) NOT NULL, + method VARCHAR(8) NOT NULL, + session_id VARCHAR(32) NOT NULL, + at BIGINT NOT NULL, + staff VARCHAR(190) NOT NULL +); +CREATE INDEX check_in_member ON check_in (member_id, at); +CREATE INDEX check_in_location ON check_in (location_id, at); + +-- The secret a member's pass codes are made from. The app computes a code +-- from it every 30 seconds with no network, so the door works when the +-- phone has no signal; the server keeps the codes it has accepted +-- (used_pass_code) so a screenshot of one gets in once. +CREATE TABLE pass_key ( + member_id VARCHAR(32) NOT NULL PRIMARY KEY, + secret VARCHAR(64) NOT NULL, + nfc_id VARCHAR(64) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX pass_key_nfc ON pass_key (nfc_id); + +CREATE TABLE used_pass_code ( + code_hash VARCHAR(64) NOT NULL PRIMARY KEY, + expires_at BIGINT NOT NULL +); + +-- Someone at the desk without a membership: a guest, a day pass, a free +-- trial. A walk-in who joins keeps the row, pointing at their account. +CREATE TABLE walk_in ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + location_id VARCHAR(32) NOT NULL, + name VARCHAR(120) NOT NULL, + email VARCHAR(190) NOT NULL, + phone VARCHAR(32) NOT NULL, + kind VARCHAR(16) NOT NULL, + amount_cents BIGINT NOT NULL, + paid_by VARCHAR(16) NOT NULL, + staff VARCHAR(190) NOT NULL, + at BIGINT NOT NULL, + converted_account VARCHAR(190) NOT NULL +); +CREATE INDEX walk_in_location ON walk_in (location_id, at); + +-- A block of work at a site. A trainer asks for a swap; another takes it. +CREATE TABLE shift ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + location_id VARCHAR(32) NOT NULL, + staff VARCHAR(190) NOT NULL, + duty VARCHAR(16) NOT NULL, + starts_at BIGINT NOT NULL, + ends_at BIGINT NOT NULL, + state VARCHAR(16) NOT NULL +); +CREATE INDEX shift_staff ON shift (staff, starts_at); diff --git a/scripts/pulse/backend/src/main/resources/db/migration/V4__billing.sql b/scripts/pulse/backend/src/main/resources/db/migration/V4__billing.sql new file mode 100644 index 00000000000..dada67220fd --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/V4__billing.sql @@ -0,0 +1,106 @@ +-- Memberships and what they cost. Money is in cents, in a BIGINT. + +-- The account a payer has with the payment processor, made the first time +-- they add a card. +CREATE TABLE payment_customer ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + reference VARCHAR(64) NOT NULL +); + +-- A saved card: what the processor calls it, and the few things about it that +-- are safe to show. No card number is ever stored here, or anywhere. +CREATE TABLE payment_method ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + brand VARCHAR(24) NOT NULL, + last4 VARCHAR(4) NOT NULL, + exp_month INTEGER NOT NULL, + exp_year INTEGER NOT NULL, + token VARCHAR(64) NOT NULL, + preferred SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX payment_method_user ON payment_method (username, created_at); + +-- An attempt to add a card, between its start and its completion. +CREATE TABLE payment_setup ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + reference VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + completed SMALLINT NOT NULL +); + +-- What can be bought. MONTHLY and ANNUAL renew; a CLASS_PACK is a number of +-- classes that lasts `period_months`; a DROP_IN is one class. +CREATE TABLE membership_plan ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(80) NOT NULL, + description VARCHAR(500) NOT NULL, + kind VARCHAR(16) NOT NULL, + price_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + period_months INTEGER NOT NULL, + class_credits INTEGER NOT NULL, + min_age INTEGER NOT NULL, + max_age INTEGER NOT NULL, + off_peak_only SMALLINT NOT NULL, + active SMALLINT NOT NULL +); + +-- A member's plan. `current_period_end` is when the next invoice is raised; +-- a freeze moves it on by the frozen days. +CREATE TABLE membership ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + member_id VARCHAR(32) NOT NULL, + account VARCHAR(190) NOT NULL, + plan_id VARCHAR(32) NOT NULL, + state VARCHAR(16) NOT NULL, + started_at BIGINT NOT NULL, + current_period_end BIGINT NOT NULL, + credits_left INTEGER NOT NULL, + frozen_from BIGINT NOT NULL, + frozen_until BIGINT NOT NULL, + cancel_at_period_end SMALLINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX membership_member ON membership (member_id, state); +CREATE INDEX membership_due ON membership (state, current_period_end); + +-- A bill. The key it is charged under is its id, so a charge retried after a +-- timeout is the same charge at the processor. A failed charge is tried again +-- at `next_attempt_at`, on the dunning ladder; `attempts` counts the tries. +CREATE TABLE invoice ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + membership_id VARCHAR(32) NOT NULL, + member_id VARCHAR(32) NOT NULL, + description VARCHAR(255) NOT NULL, + amount_cents BIGINT NOT NULL, + credit_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + status VARCHAR(16) NOT NULL, + period_start BIGINT NOT NULL, + period_end BIGINT NOT NULL, + attempts INTEGER NOT NULL, + next_attempt_at BIGINT NOT NULL, + charge_ref VARCHAR(64) NOT NULL, + method_id VARCHAR(32) NOT NULL, + failure VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + paid_at BIGINT NOT NULL +); +CREATE INDEX invoice_account ON invoice (account, created_at); +CREATE INDEX invoice_due ON invoice (status, next_attempt_at); + +-- Money owed to an account: a referral reward, a goodwill credit. Taken off +-- the next invoice. +CREATE TABLE credit ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + amount_cents BIGINT NOT NULL, + reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + invoice_id VARCHAR(32) NOT NULL +); +CREATE INDEX credit_account ON credit (account, invoice_id); diff --git a/scripts/pulse/backend/src/main/resources/db/migration/V5__push.sql b/scripts/pulse/backend/src/main/resources/db/migration/V5__push.sql new file mode 100644 index 00000000000..86d2e0d8329 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/V5__push.sql @@ -0,0 +1,13 @@ +-- The devices each account is sent push notifications on. Keyed by the hash of +-- the provider and the token, so one phone is on one account at a time: the +-- one that signed in on it last. +CREATE TABLE push_device ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + provider VARCHAR(16) NOT NULL, + token VARCHAR(1024) NOT NULL, + platform VARCHAR(32) NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX push_device_username ON push_device (username); diff --git a/scripts/pulse/backend/src/main/resources/db/migration/V6__engagement.sql b/scripts/pulse/backend/src/main/resources/db/migration/V6__engagement.sql new file mode 100644 index 00000000000..0f1f307da28 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/V6__engagement.sql @@ -0,0 +1,88 @@ +-- Keeping classes full and members coming back. + +-- A member saying they will not be in: travelling, injured. No nudge reaches +-- them meanwhile, and with `freeze_billing` their membership is frozen for +-- the days it covers. +CREATE TABLE away_period ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + starts_on BIGINT NOT NULL, + ends_on BIGINT NOT NULL, + reason VARCHAR(16) NOT NULL, + freeze_billing SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX away_period_account ON away_period (account, ends_on); + +-- A campaign an owner sent, and how it did. +CREATE TABLE campaign ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + kind VARCHAR(16) NOT NULL, + session_id VARCHAR(32) NOT NULL, + title VARCHAR(120) NOT NULL, + body VARCHAR(500) NOT NULL, + created_by VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + sent INTEGER NOT NULL, + opened INTEGER NOT NULL, + booked INTEGER NOT NULL +); + +-- One message to one account, from a campaign or from the server's own jobs. +-- Kept so nobody is nudged twice for one thing, and so a booking that follows +-- can be credited to it. +CREATE TABLE nudge ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + kind VARCHAR(16) NOT NULL, + campaign_id VARCHAR(32) NOT NULL, + session_id VARCHAR(32) NOT NULL, + title VARCHAR(120) NOT NULL, + body VARCHAR(500) NOT NULL, + sent_at BIGINT NOT NULL, + opened_at BIGINT NOT NULL, + booked_at BIGINT NOT NULL +); +CREATE INDEX nudge_account ON nudge (account, sent_at); +CREATE INDEX nudge_session ON nudge (session_id, account); + +-- Two accounts that train together. A request is one row from the asker; it +-- is accepted by writing the row back the other way. +CREATE TABLE friend ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + friend VARCHAR(190) NOT NULL, + state VARCHAR(16) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX friend_account ON friend (account, state); + +CREATE TABLE kudos ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + from_account VARCHAR(190) NOT NULL, + to_account VARCHAR(190) NOT NULL, + workout_id VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX kudos_once ON kudos (from_account, workout_id); + +-- A workout, as the phone's health store or the watch recorded it. Zones are +-- minutes in each of the five heart rate zones, comma separated. +CREATE TABLE workout ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + account VARCHAR(190) NOT NULL, + session_id VARCHAR(32) NOT NULL, + activity VARCHAR(32) NOT NULL, + started_at BIGINT NOT NULL, + minutes INTEGER NOT NULL, + avg_hr INTEGER NOT NULL, + max_hr INTEGER NOT NULL, + calories INTEGER NOT NULL, + zones VARCHAR(64) NOT NULL, + source VARCHAR(16) NOT NULL, + shared SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX workout_account ON workout (account, started_at); +CREATE INDEX workout_session ON workout (session_id); diff --git a/scripts/pulse/backend/src/main/resources/db/migration/mysql/V1__collation.sql b/scripts/pulse/backend/src/main/resources/db/migration/mysql/V1__collation.sql new file mode 100644 index 00000000000..63f1c4429fa --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/mysql/V1__collation.sql @@ -0,0 +1,7 @@ +-- Makes this database's text case sensitive and NO PAD before any table +-- exists, so every table the later scripts create inherits it. The ORM needs +-- that of a text key: "A" and "a" are two keys, and "token " keeps its +-- trailing space, as SQLite and PostgreSQL do. MySQL and MariaDB share no +-- name for that collation, so each family reads its own executable comment; +-- see scripts/savor's V10__collation.sql for the long story. +ALTER DATABASE CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; diff --git a/scripts/pulse/backend/src/main/resources/db/migration/postgresql/V1__collation.sql b/scripts/pulse/backend/src/main/resources/db/migration/postgresql/V1__collation.sql new file mode 100644 index 00000000000..fdaec9c9161 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/postgresql/V1__collation.sql @@ -0,0 +1,4 @@ +-- Nothing to do here: this engine's text is already case sensitive and NO +-- PAD. The version exists for mysql/V1__collation.sql, and every version +-- needs a script for each engine the server runs on. +SELECT 1; diff --git a/scripts/pulse/backend/src/main/resources/db/migration/sqlite/V1__collation.sql b/scripts/pulse/backend/src/main/resources/db/migration/sqlite/V1__collation.sql new file mode 100644 index 00000000000..fdaec9c9161 --- /dev/null +++ b/scripts/pulse/backend/src/main/resources/db/migration/sqlite/V1__collation.sql @@ -0,0 +1,4 @@ +-- Nothing to do here: this engine's text is already case sensitive and NO +-- PAD. The version exists for mysql/V1__collation.sql, and every version +-- needs a script for each engine the server runs on. +SELECT 1; diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessMatrixTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessMatrixTest.java new file mode 100644 index 00000000000..d3ccd47014d --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessMatrixTest.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.pulse.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Who may call each path, for every role -- with tokens earned by signing +/// in. +/// +/// A list and not a sample, because the rule for a path is written once, far +/// from the method it guards, and a path left out of it is open to everybody. +@BackendTest +class AccessMatrixTest { + /// What any signed-in user may call. `GET` first: those are also asked for + /// with a member's token, and reading changes nothing. + private static final String[] SIGNED_IN = { + "GET /api/account/preferences", + "GET /api/account/profile", + "GET /api/payments/config", + "GET /api/payments/methods", + "GET /api/household", + "GET /api/bookings", + "GET /api/bookings/history", + "GET /api/studio/locations", + "GET /api/studio/types", + "GET /api/studio/trainers", + "GET /api/studio/classes", + "GET /api/membership", + "GET /api/membership/plans", + "GET /api/membership/invoices", + "GET /api/engagement/stats", + "GET /api/engagement/away", + "GET /api/engagement/nudges", + "GET /api/engagement/workouts", + "GET /api/social/friends", + "GET /api/social/feed", + "PUT /api/account/preferences", + "PUT /api/account/profile", + "POST /api/account/password", + "POST /api/account/delete", + "POST /api/account/push", + "POST /api/bookings", + "POST /api/bookings/none/cancel", + "POST /api/household", + "POST /api/membership", + "POST /api/engagement/away", + "POST /api/engagement/heartrate", + "POST /api/social/friends", + "POST /api/social/invite", + "POST /api/payments/methods/setup", + }; + private static final String[] TRAINERS = { + "GET /api/trainer/classes", + "GET /api/trainer/shifts", + "GET /api/trainer/stats", + "GET /api/trainer/classes/none/roster", + "POST /api/trainer/classes/none/attendance", + "POST /api/trainer/shifts/none/swap", + "POST /api/trainer/shifts/none/take", + }; + private static final String[] DESK = { + "GET /api/desk/members?q=riley", + "GET /api/desk/today", + "POST /api/desk/checkin", + "POST /api/desk/walkins", + }; + private static final String[] OWNERS = { + "GET /api/owner/dashboard", + "GET /api/owner/insights", + "GET /api/owner/members?filter=AT_RISK", + "GET /api/owner/members/none", + "GET /api/owner/plans", + "GET /api/owner/rooms", + "GET /api/owner/staff", + "GET /api/owner/shifts?location=loc-downtown", + "GET /api/owner/campaigns", + "GET /api/owner/invoices", + "GET /api/admin/users", + "GET /api/admin/users/member@pulse.example", + "PUT /api/owner/plans", + "PUT /api/owner/types", + "PUT /api/owner/staff", + "POST /api/owner/classes", + "POST /api/owner/classes/none/cancel", + "POST /api/owner/shifts", + "POST /api/owner/campaigns", + "POST /api/owner/invoices/none/void", + "POST /api/admin/users/member@pulse.example/flag", + "POST /api/admin/users/member@pulse.example/roles", + "POST /api/admin/users/member@pulse.example/suspend", + }; + + @Autowired + private MockMvc mvc; + + private static MockRequestBuilder request(String call) { + int space = call.indexOf(' '); + String method = call.substring(0, space); + String path = call.substring(space + 1); + MockRequestBuilder request = "GET".equals(method) ? get(path) + : "PUT".equals(method) ? put(path) : post(path); + return "GET".equals(method) ? request + : request.contentType("application/json").content("{}"); + } + + private int answer(String call, String token) throws Exception { + MockRequestBuilder request = request(call).header("Host", SignIn.HOST); + if (token != null) { + request = request.header("Authorization", "Bearer " + token); + } + return mvc.perform(request).andReturn().getResponse().getStatus(); + } + + private void refused(String[] calls, String token, int status) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + assertEquals(status, answer(calls[iter], token), calls[iter]); + } + } + + /// The readers among `calls` get past the door: whatever they answer, it + /// is not a refusal to say who may ask. + private void admitted(String[] calls, String token) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + if (calls[iter].startsWith("GET ")) { + int status = answer(calls[iter], token); + assertTrue(status != 401 && status != 403, calls[iter] + " answered " + status); + } + } + } + + @Test + void nothingIsAnsweredWithoutSigningIn() throws Exception { + refused(SIGNED_IN, null, 401); + refused(TRAINERS, null, 401); + refused(DESK, null, 401); + refused(OWNERS, null, 401); + refused(SIGNED_IN, "not-a-token", 401); + } + + @Test + void aMemberIsKeptOutOfTheStaffSide() throws Exception { + String member = SignIn.token(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + refused(TRAINERS, member, 403); + refused(DESK, member, 403); + refused(OWNERS, member, 403); + admitted(SIGNED_IN, member); + } + + @Test + void aTrainerTeachesAndDoesNotRunTheDoorOrTheBooks() throws Exception { + String trainer = SignIn.token(mvc, DemoAccounts.TRAINER, DemoAccounts.PASSWORD); + admitted(TRAINERS, trainer); + admitted(SIGNED_IN, trainer); + refused(DESK, trainer, 403); + refused(OWNERS, trainer, 403); + } + + @Test + void theDeskRunsTheDoorOnly() throws Exception { + String desk = SignIn.token(mvc, DemoAccounts.DESK, DemoAccounts.PASSWORD); + admitted(DESK, desk); + refused(TRAINERS, desk, 403); + refused(OWNERS, desk, 403); + } + + @Test + void theOwnerReachesEverything() throws Exception { + String owner = SignIn.token(mvc, DemoAccounts.OWNER, DemoAccounts.PASSWORD); + admitted(OWNERS, owner); + admitted(DESK, owner); + admitted(TRAINERS, owner); + admitted(SIGNED_IN, owner); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessTest.java new file mode 100644 index 00000000000..e9b31f7b1cb --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccessTest.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.account.DemoAccounts; +import com.codenameone.examples.pulse.live.LiveTickets; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Who may call what, with tokens earned by signing in: these go through the +/// decoder and the role lookup the app's own requests go through. +@BackendTest +class AccessTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private LiveTickets tickets; + + private MockRequestBuilder as(MockRequestBuilder request, String token) { + return request.header("Host", SignIn.HOST).header("Authorization", "Bearer " + token); + } + + @Test + void eachRoleReachesItsOwnPartOfTheApi() throws Exception { + String member = SignIn.token(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + String trainer = SignIn.token(mvc, DemoAccounts.TRAINER, DemoAccounts.PASSWORD); + String owner = SignIn.token(mvc, DemoAccounts.OWNER, DemoAccounts.PASSWORD); + + mvc.perform(as(get("/api/me"), member)).andExpect(status().isOk()) + .andExpect(jsonPath("$.trainer").value(false)); + mvc.perform(as(get("/api/trainer/classes"), member)).andExpect(status().isForbidden()); + mvc.perform(as(get("/api/owner/dashboard"), member)).andExpect(status().isForbidden()); + + mvc.perform(as(get("/api/trainer/classes"), trainer)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/users"), trainer)).andExpect(status().isForbidden()); + // A trainer is a member too. + mvc.perform(as(get("/api/bookings"), trainer)).andExpect(status().isOk()); + + mvc.perform(as(get("/api/owner/dashboard"), owner)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/users"), owner)).andExpect(status().isOk()); + mvc.perform(as(get("/api/desk/today?location=loc-downtown"), owner)).andExpect(status().isOk()); + } + + /// Every account the owner's list shows opens, the demo crowd's included. + @Test + void everyListedAccountOpens() throws Exception { + String owner = SignIn.token(mvc, DemoAccounts.OWNER, DemoAccounts.PASSWORD); + java.util.List users = accounts.all(); + for (int iter = 0; iter < users.size(); iter++) { + String username = users.get(iter).username(); + mvc.perform(as(get("/api/admin/users/" + username), owner)).andExpect(status().isOk()) + .andExpect(jsonPath("$.user.username").value(username)); + } + } + + @Test + void aRoleGrantedOrAnAccountSuspendedTakesEffectOnTheTokenInHand() throws Exception { + String username = People.account(accounts, "promoted", false); + String token = SignIn.token(mvc, username, People.PASSWORD); + String owner = SignIn.token(mvc, DemoAccounts.OWNER, DemoAccounts.PASSWORD); + mvc.perform(as(get("/api/desk/today?location=loc-downtown"), token)).andExpect(status().isForbidden()); + + mvc.perform(as(post("/api/admin/users/" + username + "/roles"), owner) + .contentType("application/json") + .content("{\"trainer\":false,\"desk\":true,\"owner\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.desk").value(true)); + // The same token: the role is read from the account, not from it. + mvc.perform(as(get("/api/desk/today?location=loc-downtown"), token)).andExpect(status().isOk()); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), owner) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suspended").value(true)); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isUnauthorized()); + assertNull(SignIn.session(mvc, username, People.PASSWORD)); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), owner) + .contentType("application/json").content("{\"suspended\":false}")) + .andExpect(status().isOk()); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isOk()); + } + + @Test + void anOwnerCannotLockThemselvesOut() throws Exception { + String owner = SignIn.token(mvc, DemoAccounts.OWNER, DemoAccounts.PASSWORD); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.OWNER + "/roles"), owner) + .contentType("application/json") + .content("{\"trainer\":false,\"desk\":false,\"owner\":false}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.OWNER + "/suspend"), owner) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/nobody@test.example/suspend"), owner) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isNotFound()); + } + + @Test + void aLiveTicketIsGoodOnce() throws Exception { + String member = SignIn.token(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + mvc.perform(post("/api/live/ticket")).andExpect(status().isUnauthorized()); + String body = mvc.perform(as(post("/api/live/ticket"), member)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.expiresInSeconds").value(30)) + .andReturn().getResponse().getContentAsString(); + String ticket = (String) com.codename1.backend.Json.parseObject(body).get("ticket"); + assertEquals(DemoAccounts.MEMBER, tickets.redeem(ticket)); + assertNull(tickets.redeem(ticket)); + assertNull(tickets.redeem("not-a-ticket")); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccountTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccountTest.java new file mode 100644 index 00000000000..0b0bc8e9252 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/AccountTest.java @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.pulse.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Opening an account, signing in to it and proving its phone number, through +/// the same requests the app makes. +@BackendTest +class AccountTest { + @Autowired + private MockMvc mvc; + @Autowired + private DataSource db; + + private static String registration(String email, String password, boolean child) { + return "{\"email\":\"" + email + "\",\"password\":\"" + password + + "\",\"displayName\":\"Test Person\",\"phone\":\"+1 (555) 010-9999\"," + + "\"birthDate\":\"" + (child ? "2015-03-01" : "1988-03-01") + "\"}"; + } + + private String bearer(String username, String password) throws Exception { + return "Bearer " + SignIn.token(mvc, username, password); + } + + @Test + void anAccountIsOpenedThenSignedInTo() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("New.Customer@Example.com", "a-long-password", false))) + .andExpect(status().isOk()) + // The name is the address in lower case, and the phone number is + // stored in one form however it was typed. + .andExpect(jsonPath("$.username").value("new.customer@example.com")) + .andExpect(jsonPath("$.phone").value("+15550109999")) + .andExpect(jsonPath("$.member").value(true)) + .andExpect(jsonPath("$.trainer").value(false)) + .andExpect(jsonPath("$.owner").value(false)) + .andExpect(jsonPath("$.phoneVerified").value(false)); + + // Signing in is not case sensitive about the address either. + mvc.perform(get("/api/me").header("Host", SignIn.HOST).header("Authorization", + bearer("NEW.CUSTOMER@example.com", "a-long-password"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("new.customer@example.com")) + .andExpect(jsonPath("$.displayName").value("Test Person")); + } + + @Test + void anAccountThatCannotBeOpenedSaysWhy() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("short@example.com", "short", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("not-an-address", "a-long-password", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration(DemoAccounts.MEMBER, "a-long-password", false))) + .andExpect(status().isConflict()); + // A child is a member of a parent's household, not an account. + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("young@example.com", "a-long-password", true))) + .andExpect(status().isBadRequest()); + } + + @Test + void aWrongPasswordSignsNobodyIn() throws Exception { + assertNull(SignIn.session(mvc, DemoAccounts.MEMBER, "not-the-password")); + assertNotNull(SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD)); + } + + @Test + void theApiWantsAToken() throws Exception { + mvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/me").header("Authorization", "Bearer not-a-token")) + .andExpect(status().isUnauthorized()); + // A session is for signing in. It is not a way into the API. + mvc.perform(get("/api/me").cookie("CN1SESSION", + SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD))) + .andExpect(status().isUnauthorized()); + } + + @Test + void aPhoneNumberIsProvedWithTheCodeThatWasSent() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("phone@example.com", "a-long-password", false))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(false)); + String bearer = bearer("phone@example.com", "a-long-password"); + + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Map challenge = Json.parseObject(sent); + // A test server sends no text message, so it says what the code was. + String code = (String) challenge.get("demoCode"); + assertNotNull(code); + assertEquals(6, code.length()); + + // A second code straight away is refused: each is a text message. + mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isTooManyRequests()); + + String wrong = "000000".equals(code) ? "000001" : "000000"; + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + wrong + "\"}")) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + // Used up: the same code is not good twice. + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isBadRequest()); + } + + /// A second code is asked for when the first is slow to arrive, and the + /// first may then arrive last. Both are good until the first one would + /// have expired, and using either uses up both. + @Test + void aCodeStaysGoodWhenAnotherIsSentAfterIt() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("resend@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("resend@example.com", "a-long-password"); + String first = code(bearer); + // The wait between codes has passed. + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String second = code(bearer); + + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + first + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + second + "\"}")) + .andExpect(status().isBadRequest()); + + // Once the first has expired it is no longer good, though the second is. + String third = code(bearer); + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String fourth = code(bearer); + db.execute("UPDATE phone_code SET previous_expires_at = 1 WHERE username = ?", + new Object[] {"resend@example.com"}); + if (!third.equals(fourth)) { + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + third + "\"}")) + .andExpect(status().isBadRequest()); + } + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + fourth + "\"}")) + .andExpect(status().isOk()); + } + + /// Asking for another code does not give the one before a fresh set of + /// guesses: it keeps what it had left. + @Test + void theCodeBeforeKeepsTheGuessesItHadLeft() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("budget@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("budget@example.com", "a-long-password"); + String first = code(bearer); + // Four of the five guesses (PhoneVerification.MAX_ATTEMPTS) spent. + for (int iter = 0; iter < 4; iter++) { + verify(bearer, other(first, first)).andExpect(status().isBadRequest()); + } + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"budget@example.com"}); + String second = code(bearer); + // The first code's last guess, spent on a wrong one. + verify(bearer, other(first, second)).andExpect(status().isBadRequest()); + // Now the first is out of guesses, though the second is not. + if (!first.equals(second)) { + verify(bearer, first).andExpect(status().isBadRequest()); + } + verify(bearer, second).andExpect(status().isOk()); + } + + /// A six-digit code that is neither of these. + private static String other(String a, String b) { + for (int iter = 0; ; iter++) { + String candidate = String.valueOf(1000000 + iter).substring(1); + if (!candidate.equals(a) && !candidate.equals(b)) { + return candidate; + } + } + } + + private com.codename1.backend.test.ResultActions verify(String bearer, String code) + throws Exception { + return mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")); + } + + private String code(String bearer) throws Exception { + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return (String) Json.parseObject(sent).get("demoCode"); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/DaysTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/DaysTest.java new file mode 100644 index 00000000000..cfae32db442 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/DaysTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/// Time on line cut at midnight, which is how a courier's hours are credited. +class DaysTest { + /// 2026-03-01 00:00:00 UTC. + private static final long MIDNIGHT = 20513L * Days.MILLIS; + + @Test + void aStretchAcrossMidnightIsCreditedHalfToEachDay() { + // 23:59:40 to 00:00:20: twenty seconds on each side. Credited whole to + // the day it ended in, the new day had all forty. + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day - 1, 20000L, day, 20000L}, + Days.split(MIDNIGHT - 20000L, MIDNIGHT + 20000L)); + } + + @Test + void aStretchWithinOneDayIsThatDays() { + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day, 40000L}, + Days.split(MIDNIGHT + 1000L, MIDNIGHT + 41000L)); + // Ending exactly at midnight is all the old day's. + assertArrayEquals(new long[] {day - 1, 40000L}, + Days.split(MIDNIGHT - 40000L, MIDNIGHT)); + // Starting exactly at it is all the new one's. + assertArrayEquals(new long[] {day, 40000L}, Days.split(MIDNIGHT, MIDNIGHT + 40000L)); + assertArrayEquals(new long[0], Days.split(MIDNIGHT, MIDNIGHT)); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/InsightsTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/InsightsTest.java new file mode 100644 index 00000000000..046a6935754 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/InsightsTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.pulse.account.DemoAccounts; +import com.codenameone.examples.pulse.api.InsightsDto; +import com.codenameone.examples.pulse.api.MemberDetailDto; +import com.codenameone.examples.pulse.api.OwnerMemberDto; +import com.codenameone.examples.pulse.api.TrainerStatsDto; +import com.codenameone.examples.pulse.api.TypeFillDto; +import com.codenameone.examples.pulse.owner.Insights; +import com.codenameone.examples.pulse.owner.Owner; +import com.codenameone.examples.pulse.trainer.Trainers; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The owner's insights and member list, and a trainer's own numbers, over +/// the demo studio the tests start with. +@BackendTest +class InsightsTest { + @Autowired + private Insights insights; + @Autowired + private Trainers trainers; + @Autowired + private Owner owner; + + @Test + void insightsHaveTheShapeTheAppDraws() throws Exception { + InsightsDto dto = insights.insights(); + assertTrue(dto.firstHour() < dto.lastHour()); + assertEquals(7 * (dto.lastHour() - dto.firstHour()), dto.busyHours().size()); + assertEquals(12, dto.weeks().size()); + int visits = 0; + for (int iter = 0; iter < dto.busyHours().size(); iter++) { + assertTrue(dto.busyHours().get(iter).intValue() >= 0); + visits += dto.busyHours().get(iter).intValue(); + } + assertTrue(visits > 0, "the demo studio has visits"); + for (int iter = 1; iter < dto.weeks().size(); iter++) { + assertTrue(dto.weeks().get(iter).weekOf().compareTo( + dto.weeks().get(iter - 1).weekOf()) > 0, "oldest first"); + } + assertFalse(dto.classes().isEmpty()); + for (int iter = 0; iter < dto.classes().size(); iter++) { + TypeFillDto type = dto.classes().get(iter); + assertPercent(type.fillPercent()); + assertPercent(type.noShowPercent()); + } + assertFalse(dto.plans().isEmpty()); + assertFalse(dto.coaches().isEmpty()); + for (int iter = 0; iter < dto.coaches().size(); iter++) { + assertPercent(dto.coaches().get(iter).fillPercent()); + assertPercent(dto.coaches().get(iter).showPercent()); + } + assertPercent(dto.retention90()); + assertPercent(dto.noShowPercent()); + assertTrue(dto.revenuePerMemberCents() > 0); + } + + @Test + void theFiltersFindThePeopleTheyName() throws Exception { + assertTrue(has(insights.members("PAST_DUE"), DemoAccounts.FLAGGED_MEMBER)); + assertFalse(has(insights.members("PAST_DUE"), DemoAccounts.MEMBER)); + assertTrue(has(insights.members("AT_RISK"), DemoAccounts.LAPSED_MEMBER)); + List running = insights.members(""); + assertTrue(has(running, DemoAccounts.MEMBER)); + assertTrue(has(running, DemoAccounts.FLAGGED_MEMBER)); + for (int iter = 1; iter < running.size(); iter++) { + assertTrue(running.get(iter - 1).name().compareToIgnoreCase( + running.get(iter).name()) <= 0, "by name"); + } + List cancelled = insights.members("CANCELLED"); + for (int iter = 0; iter < cancelled.size(); iter++) { + assertEquals("CANCELLED", cancelled.get(iter).state()); + } + } + + @Test + void aMembersDetailTellsTheirStory() throws Exception { + OwnerMemberDto riley = null; + List running = insights.members(""); + for (int iter = 0; iter < running.size(); iter++) { + if (DemoAccounts.MEMBER.equals(running.get(iter).account()) + && "Riley Park".equals(running.get(iter).name())) { + riley = running.get(iter); + } + } + assertNotNull(riley); + MemberDetailDto detail = insights.member(riley.id()); + assertEquals(riley.id(), detail.member().id()); + assertEquals(12, detail.visitsByWeek().size()); + assertFalse(detail.invoices().isEmpty()); + assertTrue(detail.lifetimeCents() > 0); + for (int iter = 1; iter < detail.favourites().size(); iter++) { + assertTrue(detail.favourites().get(iter - 1).count() + >= detail.favourites().get(iter).count()); + } + ResponseStatusException missing = assertThrows(ResponseStatusException.class, + () -> insights.member("no-such-membership")); + assertEquals(404, missing.getStatus()); + } + + @Test + void aTrainerSeesTheirOwnMonth() throws Exception { + TrainerStatsDto tess = trainers.stats(DemoAccounts.TRAINER); + assertTrue(tess.classes() > 0, "Tess has taught"); + assertEquals(8, tess.attendeesByWeek().size()); + assertPercent(tess.fillPercent()); + assertPercent(tess.showPercent()); + assertTrue(tess.points() > 0, "Tess has points"); + assertTrue(tess.points() >= tess.levelFloor() && tess.points() < tess.nextLevel()); + assertTrue(tess.rank() >= 1 && tess.rank() <= tess.leaderboard().size()); + for (int iter = 1; iter < tess.leaderboard().size(); iter++) { + assertTrue(tess.leaderboard().get(iter - 1).count() + >= tess.leaderboard().get(iter).count(), "leaderboard is ranked"); + } + TrainerStatsDto nobody = trainers.stats("nobody@pulse.example"); + assertEquals(0, nobody.classes()); + assertEquals(0, nobody.rank()); + assertEquals(0, nobody.points()); + assertTrue(nobody.badges().isEmpty()); + assertEquals(8, nobody.attendeesByWeek().size()); + } + + @Test + void anOwnerSeesEveryLocationsShiftsWithNoLocation() throws Exception { + int all = owner.shifts("").size(); + assertTrue(all > 0, "the demo studio has shifts"); + assertTrue(all >= owner.shifts("loc-downtown").size()); + ResponseStatusException missing = assertThrows(ResponseStatusException.class, + () -> owner.shifts("no-such-location")); + assertEquals(404, missing.getStatus()); + } + + private static boolean has(List list, String account) { + for (int iter = 0; iter < list.size(); iter++) { + if (account.equals(list.get(iter).account())) { + return true; + } + } + return false; + } + + private static void assertPercent(int value) { + assertTrue(value >= 0 && value <= 100, value + " is not a percent"); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/People.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/People.java new file mode 100644 index 00000000000..9d981d57cd6 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/People.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Transactions; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.Room; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.jwt; + +/// The people, rooms and classes a test needs, made fresh for it. +/// +/// Test classes share a running server and its database for as long as they +/// can, so a test that borrowed a demo account or a demo class would meet +/// whatever the test before it left behind: a place already taken, a pass +/// code already used. Each test makes its own instead, in a room of its own, +/// with a trainer of its own, so nothing it schedules can clash with anything +/// another test scheduled. +public final class People { + public static final String PASSWORD = "a-long-password"; + private static final AtomicInteger NEXT = new AtomicInteger(); + private static final long DAY = 86400000L; + + private People() { + } + + /// Work a test does on the database directly, which has to be inside a + /// transaction as any repository call has. + public interface Step { + T run() throws Exception; + } + + /// Runs `step` in a transaction of its own and answers what it answered. + public static T tx(Step step) throws Exception { + final Object[] out = new Object[1]; + Transactions.execute(() -> { + out[0] = step.run(); + return null; + }); + @SuppressWarnings("unchecked") + T answer = (T) out[0]; + return answer; + } + + /// A name no other test has used. + public static String unique(String stem) { + return stem + System.nanoTime() % 1000000000L + "-" + NEXT.incrementAndGet(); + } + + /// Opens an adult member's account with the waiver signed and an + /// unlimited membership, and returns its name. + public static String member(Accounts accounts, MemberRepository members, + MembershipRepository memberships, String name) throws Exception { + return member(accounts, members, memberships, name, "plan-unlimited", 0); + } + + /// The same on any plan; `credits` for a pack. + public static String member(Accounts accounts, MemberRepository members, + MembershipRepository memberships, String name, String planId, int credits) + throws Exception { + String username = account(accounts, name, false); + Member self = self(members, username); + self.setWaiverAt(Instant.now()); + tx(() -> { members.save(self); return null; }); + tx(() -> { memberships.insert(membership(username, self.getId(), planId, credits)); return null; }); + return username; + } + + public static Membership membership(String account, String memberId, String planId, + int credits) throws Exception { + Instant now = Instant.now(); + Membership row = new Membership(); + row.setId(Ids.next()); + row.setMemberId(memberId); + row.setAccount(account); + row.setPlanId(planId); + row.setState("ACTIVE"); + row.setStartedAt(now); + row.setCurrentPeriodEnd(now.plusMillis(30L * DAY)); + row.setCreditsLeft(credits); + row.setUpdatedAt(now); + return row; + } + + /// A trainer of the test's own. + public static String trainer(Accounts accounts, String name) throws Exception { + return account(accounts, name, true); + } + + public static String account(Accounts accounts, String name, boolean trainer) + throws Exception { + String username = unique(name) + "@test.example"; + accounts.create(username, PASSWORD, name, "+15550111", true, LocalDate.of(1990, 5, 1), + trainer, false, false, System.currentTimeMillis()); + return username; + } + + /// The member who is the account holder. + public static Member self(MemberRepository members, String username) throws Exception { + return tx(() -> members.findFirstByAccountAndRelation(username, Accounts.SELF)); + } + + /// A room of the test's own, downtown. + public static Room room(RoomRepository rooms, int capacity) throws Exception { + Room row = new Room(); + row.setId(Ids.next()); + row.setLocationId("loc-downtown"); + row.setName(unique("Room ")); + row.setCapacity(capacity); + tx(() -> { rooms.insert(row); return null; }); + return row; + } + + /// A class `days` days from now, inserted as the timetable would. + public static ClassSession session(ClassSessionRepository sessions, String type, Room room, + String trainer, int capacity, long startsAt) throws Exception { + ClassSession row = new ClassSession(); + row.setId(Ids.next()); + row.setClassTypeId(type); + row.setLocationId(room.getLocationId()); + row.setRoomId(room.getId()); + row.setTrainer(trainer); + row.setStartsAt(Instant.ofEpochMilli(startsAt)); + row.setEndsAt(Instant.ofEpochMilli(startsAt + 45L * 60000L)); + row.setCapacity(capacity); + row.setState("SCHEDULED"); + tx(() -> { sessions.insert(row); return null; }); + return row; + } + + /// A time `days` days ahead at 11:00 UTC, off peak at the studio. + public static long daysAhead(int days) { + long now = System.currentTimeMillis(); + return (now / DAY + days) * DAY + 11L * 3600000L; + } + + /// The request, made as a member. The authorities are stated here and not + /// read from the account: these are the tests of what a role may do, and + /// `AccessTest` is the one that signs in for real. + public static MockRequestBuilder asMember(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_MEMBER")); + } + + public static MockRequestBuilder asTrainer(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_MEMBER", "ROLE_TRAINER")); + } + + public static MockRequestBuilder asDesk(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_MEMBER", "ROLE_DESK")); + } + + public static MockRequestBuilder asOwner(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_MEMBER", "ROLE_OWNER")); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/SignIn.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/SignIn.java new file mode 100644 index 00000000000..3262f894d39 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/SignIn.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Json; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; + +/// Signs a user in the way the app does, for the tests that need a real token: +/// the password for a session, the session for an authorization code, the code +/// for tokens. +final class SignIn { + /// The PKCE pair of RFC 7636's appendix B. + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + static final String HOST = "pulse.test:8080"; + + private SignIn() { + } + + static MockRequestBuilder formPost(String path, String... pairs) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < pairs.length; i += 2) { + if (body.length() > 0) { + body.append('&'); + } + body.append(OAuth2Parameters.encode(pairs[i])).append('=') + .append(OAuth2Parameters.encode(pairs[i + 1])); + } + return post(path).contentType("application/x-www-form-urlencoded").content(body.toString()); + } + + /// The session cookie a correct password earns, or null for a wrong one. + static String session(MockMvc mvc, String username, String password) throws Exception { + return mvc.perform(formPost("/login", "username", username, "password", password) + .header("Host", HOST)) + .andReturn().getResponse().getCookie("CN1SESSION"); + } + + /// The token response for a user: `access_token`, `refresh_token` and the + /// rest. + static Map tokens(MockMvc mvc, String username, String password) throws Exception { + String session = session(mvc, username, password); + if (session == null) { + throw new IllegalStateException("could not sign in as " + username); + } + String location = mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + + OAuth2Parameters.encode(SecurityConfig.LOOPBACK_REDIRECT) + + "&scope=openid%20profile&state=s-1&nonce=n-1&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + if (location == null || location.indexOf("code=") < 0) { + throw new IllegalStateException("no authorization code: " + location); + } + int at = location.indexOf("code=") + 5; + int end = location.indexOf('&', at); + String code = location.substring(at, end < 0 ? location.length() : end); + String body = mvc.perform(formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", SecurityConfig.LOOPBACK_REDIRECT, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", HOST)) + .andReturn().getResponse().getContentAsString(); + return Json.parseObject(body); + } + + /// A bearer token for a user. + static String token(MockMvc mvc, String username, String password) throws Exception { + Object token = tokens(mvc, username, password).get("access_token"); + if (!(token instanceof String)) { + throw new IllegalStateException("no access token for " + username); + } + return (String) token; + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StripePaymentsTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StripePaymentsTest.java new file mode 100644 index 00000000000..785d721cbd4 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StripePaymentsTest.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codenameone.examples.pulse.pay.PaymentDeclinedException; +import com.codenameone.examples.pulse.pay.PaymentProvider; +import com.codenameone.examples.pulse.pay.StripePayments; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The Stripe provider, held to what it sends and what it makes of the +/// answers. The network is replaced by a script of canned answers, so this +/// needs no key and reaches nobody: what it proves is the requests' shape and +/// the reading of Stripe's JSON, which is all of the provider there is. +class StripePaymentsTest { + private static final String RETURN = "https://pulse.example/pay/return"; + + /// Answers each request with the next canned reply and keeps what was + /// asked. + private static final class Script implements StripePayments.Transport { + final List asked = new ArrayList(); + final List forms = new ArrayList(); + final List keys = new ArrayList(); + private final List replies = new ArrayList(); + + Script reply(int status, String body) { + replies.add(new StripePayments.Reply(status, body)); + return this; + } + + @Override + public StripePayments.Reply send(String method, String path, String form, + String idempotencyKey) throws IOException { + asked.add(method + " " + path); + forms.add(form); + keys.add(idempotencyKey); + if (replies.isEmpty()) { + throw new IOException("Nothing more was expected to be asked"); + } + return replies.remove(0); + } + } + + private static void has(String form, String pair) { + assertTrue(("&" + form + "&").indexOf("&" + pair + "&") >= 0, pair + " in " + form); + } + + @Test + void aCustomerIsMadeWithTheAccountsName() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("stripe", stripe.name()); + assertEquals("cus_123", stripe.createCustomer("ada@pulse.example", "Ada & Co")); + assertEquals("POST /v1/customers", script.asked.get(0)); + String form = script.forms.get(0); + // Escaped, name and value both: an ampersand in a name must not start + // another field. + has(form, "email=ada%40pulse.example"); + has(form, "name=Ada+%26+Co"); + has(form, "metadata%5Bpulse_user%5D=ada%40pulse.example"); + // The same request made twice makes one customer: the same key, which + // does not spell the address out. + String key = script.keys.get(0); + assertTrue(key.startsWith("customer-") && key.indexOf("pulse.example") < 0, key); + script.reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@pulse.example", "Ada & Co"); + assertEquals(key, script.keys.get(1)); + script.reply(200, "{\"id\":\"cus_124\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@pulse.example", "Ada"); + assertTrue(!key.equals(script.keys.get(2)), "another name is another request"); + } + + @Test + void aCustomerIsDeletedAndOneAlreadyGoneCountsAsDeleted() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"deleted\":true}") + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.forgetCustomer("cus_123"); + assertEquals("DELETE /v1/customers/cus_123", script.asked.get(0)); + assertEquals(null, script.forms.get(0)); + // Asked again after an answer that was lost. + stripe.forgetCustomer("cus_123"); + // Anything else is not a deletion, and the closure it is part of fails. + try { + stripe.forgetCustomer("cus_123"); + throw new AssertionError("A processor that failed must not read as deleted"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("500") >= 0, expected.getMessage()); + } + } + + @Test + void aRemovedCardIsDetachedAndOneAlreadyDetachedCountsAsDetached() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Gone with its customer. + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + // Asked again after an answer that was lost: Stripe refuses to + // detach a card attached to nobody, and the card says so. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Refused while the card is still this customer's: not detached. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\"," + + "\"customer\":\"cus_123\"}") + .reply(500, "{}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("POST /v1/payment_methods/pm_1/detach", script.asked.get(0)); + stripe.detachCard("cus_123", "pm_1"); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("GET /v1/payment_methods/pm_1", script.asked.get(3)); + for (int iter = 0; iter < 2; iter++) { + try { + stripe.detachCard("cus_123", "pm_1"); + throw new AssertionError("A card still attached must not read as detached"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("detaching") >= 0, + expected.getMessage()); + } + } + } + + @Test + void aCardIsAddedOnAHostedPageAndReadBack() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"cs_test_1\",\"object\":\"checkout.session\"," + + "\"url\":\"https://checkout.stripe.com/c/pay/cs_test_1\"}") + .reply(200, "{\"id\":\"cs_test_1\",\"status\":\"complete\"," + + "\"customer\":\"cus_123\",\"setup_intent\":{\"id\":\"seti_1\"," + + "\"status\":\"succeeded\",\"payment_method\":{\"id\":\"pm_1\"," + + "\"type\":\"card\",\"card\":{\"brand\":\"visa\",\"last4\":\"4242\"," + + "\"exp_month\":8,\"exp_year\":2031}}}}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Setup setup = stripe.startCardSetup("cus_123", "setup9"); + assertTrue(setup.hosted); + assertEquals("cs_test_1", setup.reference); + assertEquals("https://checkout.stripe.com/c/pay/cs_test_1", setup.url); + assertEquals("POST /v1/checkout/sessions", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "mode=setup"); + has(form, "customer=cus_123"); + has(form, "payment_method_types%5B0%5D=card"); + has(form, "client_reference_id=setup9"); + has(form, "success_url=https%3A%2F%2Fpulse.example%2Fpay%2Freturn%3Fresult%3Ddone"); + has(form, "cancel_url=https%3A%2F%2Fpulse.example%2Fpay%2Freturn%3Fresult%3Dcancelled"); + + // No card is sent: it was typed on the processor's page. + PaymentProvider.Card card = stripe.finishCardSetup("cus_123", "cs_test_1", null); + assertEquals("GET /v1/checkout/sessions/cs_test_1" + + "?expand%5B%5D=setup_intent.payment_method", script.asked.get(1)); + assertEquals(null, script.forms.get(1)); + assertEquals("pm_1", card.token); + assertEquals("Visa", card.brand); + assertEquals("4242", card.last4); + assertEquals(8, card.expMonth); + assertEquals(2031, card.expYear); + } + + @Test + void aSetupNotFinishedOrSomebodyElsesSavesNoCard() throws Exception { + // The user closed the page: the session is still open, with no card. + Script script = new Script().reply(200, "{\"id\":\"cs_test_2\",\"status\":\"open\"," + + "\"customer\":\"cus_123\",\"setup_intent\":null}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_2", null); + fail("An unfinished setup saved a card"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("not added") > 0); + } + script = new Script().reply(200, "{\"id\":\"cs_test_3\",\"status\":\"complete\"," + + "\"customer\":\"cus_999\",\"setup_intent\":{\"status\":\"succeeded\"," + + "\"payment_method\":{\"id\":\"pm_9\",\"card\":{\"brand\":\"visa\"," + + "\"last4\":\"1111\",\"exp_month\":1,\"exp_year\":2030}}}}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_3", null); + fail("Another customer's session saved a card"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("another customer") > 0); + } + // A page to open is the whole point of starting one. + script = new Script().reply(200, "{\"id\":\"cs_test_4\"}"); + try { + new StripePayments(script, RETURN).startCardSetup("cus_123", "setup1"); + fail("A setup with no page was started"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("no page") > 0); + } + } + + @Test + void aDeliveryIsChargedOnceWithNobodyAtTheScreen() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_1\",\"object\":\"payment_intent\"," + + "\"status\":\"succeeded\",\"amount\":1840}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("pi_1", stripe.charge("delivery-77", "cus_123", "pm_1", 1840L, "USD", + "Pulse delivery")); + assertEquals("POST /v1/payment_intents", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "amount=1840"); + has(form, "currency=usd"); + has(form, "customer=cus_123"); + has(form, "payment_method=pm_1"); + has(form, "off_session=true"); + has(form, "confirm=true"); + has(form, "description=Pulse+delivery"); + has(form, "metadata%5Bpulse_charge%5D=delivery-77"); + // The key that makes a charge sent twice a charge made once. + assertEquals("delivery-77", script.keys.get(0)); + } + + @Test + void aChargeTheBankWantsApprovedOrRefusesIsADecline() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_2\"," + + "\"status\":\"requires_action\"}"); + try { + new StripePayments(script, RETURN).charge("k1", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A charge awaiting approval was taken as made"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("approval") > 0); + } + // What Stripe answers for a card that is refused: 402, and words + // written for the cardholder. + script = new Script().reply(402, "{\"error\":{\"type\":\"card_error\"," + + "\"code\":\"card_declined\",\"decline_code\":\"insufficient_funds\"," + + "\"message\":\"Your card has insufficient funds.\"}}"); + try { + new StripePayments(script, RETURN).charge("k2", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A declined charge was taken as made"); + } catch (PaymentDeclinedException expected) { + assertEquals("Your card has insufficient funds.", expected.getMessage()); + } + // Anything else is a fault, reported by status and code and without + // the processor's message, which can quote the request. + script = new Script().reply(401, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"api_key_invalid\",\"message\":\"Invalid API Key provided: sk_x\"}}"); + try { + new StripePayments(script, RETURN).charge("k3", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A request the processor rejected was taken as made"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("401") > 0, expected.getMessage()); + assertTrue(expected.getMessage().indexOf("api_key_invalid") > 0); + assertEquals(-1, expected.getMessage().indexOf("sk_x")); + } + script = new Script().reply(502, "Bad gateway"); + try { + new StripePayments(script, RETURN).charge("k4", "cus_123", "pm_1", 500L, "USD", "x"); + fail("An answer that is not JSON was taken as a charge"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("502") > 0, expected.getMessage()); + } + } + + @Test + void aRefundReturnsTheChargeOnce() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_1\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_2\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Refund made = stripe.refund("pi_1", 1840L); + assertEquals("re_1", made.reference); + assertFalse(made.pending); + assertEquals("POST /v1/refunds", script.asked.get(0)); + has(script.forms.get(0), "payment_intent=pi_1"); + has(script.forms.get(0), "amount=1840"); + assertEquals("refund-pi_1", script.keys.get(0)); + try { + stripe.refund("pi_1", 1840L); + fail("A refund that failed was taken as made"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("failed") > 0); + } + } + + @Test + void aPendingRefundIsAskedAboutUntilItEnds() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\"," + + "\"status\":\"requires_action\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\",\"status\":\"canceled\"}") + .reply(200, "{\"id\":\"re_5\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + // Taken, but not yet money back: neither is a refund to record as made. + PaymentProvider.Refund pending = stripe.refund("pi_3", 900L); + assertEquals("re_3", pending.reference); + assertTrue(pending.pending); + assertTrue(stripe.refund("pi_4", 900L).pending); + assertEquals(PaymentProvider.REFUND_PENDING, stripe.refundStatus("re_3")); + assertEquals("GET /v1/refunds/re_3", script.asked.get(2)); + assertEquals(PaymentProvider.REFUND_SUCCEEDED, stripe.refundStatus("re_3")); + // Canceled returned no more money than failed did. + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_4")); + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_5")); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StudioTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StudioTest.java new file mode 100644 index 00000000000..c16c8a47fe5 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/StudioTest.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.BookRequestDto; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.CheckInRequestDto; +import com.codenameone.examples.pulse.api.CheckInResultDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.NewMemberDto; +import com.codenameone.examples.pulse.api.PassDto; +import com.codenameone.examples.pulse.desk.Desk; +import com.codenameone.examples.pulse.domain.Booking; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.Room; +import com.codenameone.examples.pulse.repository.BookingRepository; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; +import com.codenameone.examples.pulse.studio.Bookings; +import com.codenameone.examples.pulse.studio.Household; +import com.codenameone.examples.pulse.studio.Passes; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The studio's promises: a class never holds more people than it has +/// places, a place given back goes to the first in the queue, a class is for +/// the ages it says, and the door lets in each pass once. +@BackendTest +class StudioTest { + @Autowired + private Accounts accounts; + @Autowired + private MemberRepository members; + @Autowired + private MembershipRepository memberships; + @Autowired + private RoomRepository rooms; + @Autowired + private ClassSessionRepository sessions; + @Autowired + private BookingRepository bookings; + @Autowired + private Bookings booking; + @Autowired + private Household household; + @Autowired + private Desk desk; + + private ClassSession hiit(int capacity, int days) throws Exception { + Room room = People.room(rooms, 30); + String trainer = People.trainer(accounts, "Coach"); + return People.session(sessions, "type-hiit", room, trainer, capacity, + People.daysAhead(days)); + } + + private String member(String name) throws Exception { + return People.member(accounts, members, memberships, name); + } + + @Test + void manyBookingAtOnceNeverOverfillAClass() throws Exception { + ClassSession session = hiit(3, 5); + int people = 8; + final List who = new ArrayList(); + for (int iter = 0; iter < people; iter++) { + who.add(member("Racer")); + } + final String id = session.getId(); + final AtomicInteger failed = new AtomicInteger(); + List threads = new ArrayList(); + for (int iter = 0; iter < people; iter++) { + final String account = who.get(iter); + Thread thread = new Thread(() -> { + try { + booking.book(account, new BookRequestDto(id, null, null)); + } catch (Exception err) { + failed.incrementAndGet(); + } + }); + threads.add(thread); + } + for (int iter = 0; iter < threads.size(); iter++) { + threads.get(iter).start(); + } + for (int iter = 0; iter < threads.size(); iter++) { + threads.get(iter).join(60000L); + } + int booked = 0; + int waiting = 0; + List rows = People.tx(() -> bookings.findBySessionIdOrderByCreatedAtAsc(id)); + for (int iter = 0; iter < rows.size(); iter++) { + if (Bookings.BOOKED.equals(rows.get(iter).getState())) { + booked++; + } else if (Bookings.WAITLISTED.equals(rows.get(iter).getState())) { + waiting++; + } + } + ClassSession after = People.tx(() -> sessions.findById(id)); + assertEquals(3, booked, "places taken"); + assertEquals(3, after.getBooked()); + assertEquals(waiting, after.getWaitlisted()); + assertEquals(people, booked + waiting + failed.get()); + } + + @Test + void aPlaceGivenBackGoesToTheFirstInTheQueue() throws Exception { + ClassSession session = hiit(1, 6); + String first = member("First"); + String second = member("Second"); + String third = member("Third"); + BookingDto placed = booking.book(first, new BookRequestDto(session.getId(), null, null)); + BookingDto queued = booking.book(second, new BookRequestDto(session.getId(), null, null)); + BookingDto later = booking.book(third, new BookRequestDto(session.getId(), null, null)); + assertEquals(Bookings.BOOKED, placed.state()); + assertEquals(Bookings.WAITLISTED, queued.state()); + assertEquals(1, queued.position()); + assertEquals(2, later.position()); + + // Asking again answers the booking already held, not a second one. + assertEquals(queued.id(), + booking.book(second, new BookRequestDto(session.getId(), null, null)).id()); + + booking.cancel(first, placed.id()); + assertEquals(Bookings.BOOKED, People.tx(() -> bookings.findById(queued.id())).getState()); + assertEquals(Bookings.WAITLISTED, People.tx(() -> bookings.findById(later.id())).getState()); + ClassSession after = People.tx(() -> sessions.findById(session.getId())); + assertEquals(1, after.getBooked()); + assertEquals(1, after.getWaitlisted()); + } + + @Test + void aChildCannotBookAnAdultsClass() throws Exception { + ClassSession session = hiit(10, 7); + String parent = member("Parent"); + LocalDate born = LocalDate.now().minusYears(7); + MemberDto child = household.addChild(parent, new NewMemberDto("Little One", + born.toString(), "")); + household.signWaiver(parent, child.id()); + ResponseStatusException refused = assertThrows(ResponseStatusException.class, + () -> booking.book(parent, new BookRequestDto(session.getId(), child.id(), null))); + assertEquals(403, refused.getStatus()); + assertTrue(refused.getReason().contains("Little One"), refused.getReason()); + + // The parent, on the same membership, is welcome. + assertEquals(Bookings.BOOKED, + booking.book(parent, new BookRequestDto(session.getId(), null, null)).state()); + } + + @Test + void aMemberOfAnotherHouseholdCannotBeBookedFor() throws Exception { + ClassSession session = hiit(10, 8); + String one = member("One"); + String other = member("Other"); + Member theirs = People.self(members, other); + ResponseStatusException refused = assertThrows(ResponseStatusException.class, + () -> booking.book(one, new BookRequestDto(session.getId(), theirs.getId(), null))); + assertEquals(404, refused.getStatus()); + } + + @Test + void aPassLetsSomeoneInOnceAndNotAfterItExpires() throws Exception { + String account = member("Door"); + Member self = People.self(members, account); + PassDto pass = household.pass(account, self.getId()); + long now = System.currentTimeMillis(); + String scanned = "pulse:" + self.getId() + ":" + Passes.code(pass.secret(), now); + + CheckInResultDto first = desk.checkIn("desk", new CheckInRequestDto(scanned, null, null, + "loc-downtown")); + assertTrue(first.admitted(), first.reason()); + CheckInResultDto replay = desk.checkIn("desk", new CheckInRequestDto(scanned, null, null, + "loc-downtown")); + assertFalse(replay.admitted()); + assertTrue(replay.reason().contains("already used"), replay.reason()); + + String stale = "pulse:" + self.getId() + ":" + + Passes.code(pass.secret(), now - 10L * 60000L); + CheckInResultDto expired = desk.checkIn("desk", new CheckInRequestDto(stale, null, null, + "loc-downtown")); + assertFalse(expired.admitted()); + + // A code made with someone else's secret is not theirs. + String forged = "pulse:" + self.getId() + ":" + + Passes.code("00112233445566778899aabbccddeeff00112233", now); + assertFalse(desk.checkIn("desk", new CheckInRequestDto(forged, null, null, + "loc-downtown")).admitted()); + } + + @Test + void theDoorTurnsAwayAMemberWhoOwes() throws Exception { + String account = member("Owes"); + Member self = People.self(members, account); + Membership held = People.tx(() -> household.current(self.getId())); + held.setState("PAST_DUE"); + People.tx(() -> { memberships.save(held); return null; }); + PassDto pass = household.pass(account, self.getId()); + String scanned = "pulse:" + self.getId() + ":" + + Passes.code(pass.secret(), System.currentTimeMillis()); + CheckInResultDto result = desk.checkIn("desk", new CheckInRequestDto(scanned, null, null, + "loc-downtown")); + assertFalse(result.admitted()); + assertTrue(result.reason().contains("overdue"), result.reason()); + assertEquals("PAST_DUE", result.membershipState()); + + // And the app will not book them either. + ClassSession session = hiit(10, 9); + ResponseStatusException refused = assertThrows(ResponseStatusException.class, + () -> booking.book(account, new BookRequestDto(session.getId(), null, null))); + assertEquals(402, refused.getStatus()); + } + + @Test + void anNfcFobIsNotEnoughWithoutAMembership() throws Exception { + String account = People.account(accounts, "NoPlan", false); + Member self = People.self(members, account); + self.setWaiverAt(java.time.Instant.now()); + People.tx(() -> { members.save(self); return null; }); + PassDto pass = household.pass(account, self.getId()); + CheckInResultDto result = desk.checkIn("desk", new CheckInRequestDto(null, pass.nfcId(), + null, "loc-downtown")); + assertFalse(result.admitted()); + assertTrue(result.reason().contains("No membership"), result.reason()); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/WebSignInTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/WebSignInTest.java new file mode 100644 index 00000000000..9959d26d3f8 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/WebSignInTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.pulse.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Signing in from the app running in a browser, which sees only how each +/// request ended: no redirect it can stop at, and no cookie it can read. +@BackendTest +class WebSignInTest { + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + private static final String HERE = "http://" + SignIn.HOST + SecurityConfig.WEB_REDIRECT_PATH; + + @Autowired + private MockMvc mvc; + + private String authorize(String session, String redirect) throws Exception { + return mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + OAuth2Parameters.encode(redirect) + + "&scope=openid%20profile&state=s-web&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", SignIn.HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + } + + @Test + void thePasswordIsAnsweredWithAStatus() throws Exception { + MockResponse accepted = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + // Not a redirect: a browser would follow one and report the page at its end. + assertNull(accepted.getRedirectedUrl()); + assertNotNull(accepted.getCookie("CN1SESSION")); + + MockResponse refused = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", "not-the-password") + .header("Host", SignIn.HOST)) + .andExpect(status().isUnauthorized()).andReturn().getResponse(); + assertNull(refused.getRedirectedUrl()); + assertNull(refused.getCookie("CN1SESSION")); + } + + @Test + void theCodeComesBackThroughThisServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertNotNull(location); + assertTrue(location.startsWith(HERE + "?"), location); + + // What the browser does next, and what the app reads. + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("s-web")) + .andReturn().getResponse().getContentAsString(); + String code = (String) Json.parseObject(answer).get("code"); + assertNotNull(code); + + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + String token = (String) tokens.get("access_token"); + assertNotNull(token); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void aCodeIsNeverSentToAnotherServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + // Same path, somebody else's server: the address has to be this one. + String elsewhere = authorize(session, "http://attacker.example" + SecurityConfig.WEB_REDIRECT_PATH); + assertTrue(elsewhere == null || elsewhere.indexOf("attacker.example") < 0, String.valueOf(elsewhere)); + String otherPath = authorize(session, "http://" + SignIn.HOST + "/somewhere/else"); + assertTrue(otherPath == null || otherPath.indexOf("code=") < 0, String.valueOf(otherPath)); + } + + @Test + void theInstalledAppStillSignsInItsOwnWay() throws Exception { + assertNotNull(SignIn.token(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD)); + } + + @Test + void theCodePageSaysWhenThereIsNoCode() throws Exception { + mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + "?error=access_denied&state=s-web") + .header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("access_denied")); + } + + @Test + void aSignInAnotherSiteSubmittedIsRefused() throws Exception { + MockResponse forged = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()).andReturn().getResponse(); + assertNull(forged.getCookie("CN1SESSION")); + // A page in a sandboxed frame, or opened from a file. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "null")) + .andExpect(status().isForbidden()); + // The same name on another port or scheme is another site. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "https://" + SignIn.HOST)) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://pulse.test:9090")) + .andExpect(status().isForbidden()); + // The rest of what changes something on this chain is held to it too. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "authorization_code") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + } + + @Test + void aSignInFromThisServersOwnPageIsAccepted() throws Exception { + // A browser spells the host in lower case; the comparison does not care. + MockResponse own = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "HTTP://PULSE.TEST:8080")) + .andExpect(status().isOk()).andReturn().getResponse(); + assertNotNull(own.getCookie("CN1SESSION")); + // And the installed app, which is not a browser and names no origin. + assertNotNull(SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD)); + } + + @Test + void theOriginIsHeldToTheIssuerWhenOneIsConfigured() { + assertTrue(SecurityConfig.sameOrigin("https://pulse.example", "https://pulse.example")); + assertTrue(SecurityConfig.sameOrigin("https://PULSE.example", "https://pulse.example/auth")); + assertTrue(!SecurityConfig.sameOrigin("https://pulse.example.attacker.example", + "https://pulse.example")); + assertTrue(!SecurityConfig.sameOrigin("http://pulse.example", "https://pulse.example")); + assertTrue(!SecurityConfig.sameOrigin("https://pulse.example:8443", "https://pulse.example")); + // With an issuer the address a request arrived at is never consulted. + assertEquals("https://pulse.example", SecurityConfig.here("https://pulse.example")); + // With none, and no request in hand, there is no address to accept. + assertNull(SecurityConfig.here("")); + } + + @Test + void theSessionCookieIsNotSentFromOtherSitesOrReadByScripts() throws Exception { + String header = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.MEMBER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getHeader("Set-Cookie"); + assertNotNull(header); + assertTrue(header.indexOf("HttpOnly") > 0, header); + assertTrue(header.indexOf("SameSite=Lax") > 0 || header.indexOf("SameSite=Strict") > 0, header); + } + + @Test + void theSessionEndsWhenTheAppHasItsTokens() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.MEMBER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertTrue(location.indexOf("code=") > 0, location); + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString(); + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", (String) Json.parseObject(answer).get("code"), + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString()); + + // Asking nicely does not end it: only a POST does. + mvc.perform(get("/logout").header("Host", SignIn.HOST).cookie("CN1SESSION", session)); + assertTrue(authorize(session, HERE).indexOf("code=") > 0); + + MockResponse ended = mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://" + SignIn.HOST) + .cookie("CN1SESSION", session)) + .andExpect(status().isNoContent()).andReturn().getResponse(); + assertNull(ended.getRedirectedUrl()); + // The cookie earns no more codes... + String after = authorize(session, HERE); + assertTrue(after == null || after.indexOf("code=") < 0, String.valueOf(after)); + // ...and the tokens it earned are untouched. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + tokens.get("access_token"))) + .andExpect(status().isOk()); + mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "refresh_token", + "refresh_token", (String) tokens.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()); + } + + @Test + void theCodePageIsDataAndNothingElse() throws Exception { + MockResponse page = mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + + "?code=%3Cscript%3Ealert(1)%3C/script%3E&state=s").header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + assertTrue(String.valueOf(page.getContentType()).startsWith("application/json"), + String.valueOf(page.getContentType())); + assertEquals("nosniff", page.getHeader("X-Content-Type-Options")); + assertEquals("no-store", page.getHeader("Cache-Control")); + } + + @Test + void anIssuerIsTheOnlyWebAddressWhenOneIsConfigured() { + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient app = + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient + .withId("pulse").clientId(SecurityConfig.CLIENT_ID) + .clientAuthenticationMethod(com.codename1.backend.security.oauth2.core + .ClientAuthenticationMethod.NONE) + .authorizationGrantType(com.codename1.backend.security.oauth2.core + .AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(SecurityConfig.LOOPBACK_REDIRECT).build(); + java.util.Set uris = SecurityConfig.withWebRedirect(app, "https://pulse.example") + .getRedirectUris(); + assertTrue(uris.contains("https://pulse.example/signin/code"), uris.toString()); + assertTrue(uris.contains(SecurityConfig.LOOPBACK_REDIRECT), uris.toString()); + assertEquals(2, uris.size(), uris.toString()); + // With none configured and no request in hand, nothing is added. + assertEquals(1, SecurityConfig.withWebRedirect(app, "").getRedirectUris().size()); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/billing/BillingTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/billing/BillingTest.java new file mode 100644 index 00000000000..a93ebf481a9 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/billing/BillingTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.billing; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.pulse.People; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.CardDto; +import com.codenameone.examples.pulse.api.JoinDto; +import com.codenameone.examples.pulse.api.MembershipDto; +import com.codenameone.examples.pulse.domain.Invoice; +import com.codenameone.examples.pulse.domain.Member; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.pay.Payments; +import com.codenameone.examples.pulse.repository.InvoiceRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Getting paid: joining, renewing, the dunning ladder and proration. +@BackendTest +class BillingTest { + private static final String GOOD = "4242424242424242"; + private static final String FAILS_LATER = "4000000000000341"; + private static final long DAY = 86400000L; + + @Autowired + private Accounts accounts; + @Autowired + private MemberRepository members; + @Autowired + private MembershipRepository memberships; + @Autowired + private InvoiceRepository invoices; + @Autowired + private Payments payments; + @Autowired + private Billing billing; + + private String card(String account, String number) throws Exception { + String setup = payments.startSetup(account, "Card Holder").id(); + return payments.completeSetup(account, setup, new CardDto(number, 12, 2035, "123", + "Card Holder")).id(); + } + + private String signed(String name) throws Exception { + String account = People.account(accounts, name, false); + Member self = People.self(members, account); + self.setWaiverAt(Instant.now()); + People.tx(() -> { members.save(self); return null; }); + return account; + } + + @Test + void theUnusedPartOfAPeriodIsGivenBackToTheCent() { + Instant end = Instant.parse("2026-02-01T00:00:00Z"); + // Half of January's 31 days left. + assertEquals(1500, Billing.prorate(3000, end, 1, Instant.parse("2026-01-16T12:00:00Z"))); + assertEquals(3000, Billing.prorate(3000, end, 1, Instant.parse("2025-12-20T00:00:00Z"))); + assertEquals(0, Billing.prorate(3000, end, 1, end)); + // Rounded down: never more back than was paid for. + assertEquals(96, Billing.prorate(100, end, 1, Instant.parse("2026-01-02T00:00:00Z"))); + // An annual plan. + assertEquals(5999, Billing.prorate(11999, Instant.parse("2027-01-01T00:00:00Z"), 12, + Instant.parse("2026-07-02T12:00:00Z"))); + } + + @Test + void joiningChargesTheFirstPeriod() throws Exception { + String account = signed("Joiner"); + String good = card(account, GOOD); + MembershipDto joined = billing.join(account, new JoinDto(null, "plan-unlimited", good)); + assertEquals(Billing.ACTIVE, joined.state()); + List bills = People.tx(() -> invoices.findByAccountOrderByCreatedAtDesc(account)); + assertEquals(1, bills.size()); + assertEquals(Billing.PAID, bills.get(0).getStatus()); + + // A second join on the same plan is refused; switching plans credits + // the unused time against the new one. + ResponseStatusException again = assertThrows(ResponseStatusException.class, + () -> billing.join(account, new JoinDto(null, "plan-unlimited", good))); + assertEquals(409, again.getStatus()); + billing.join(account, new JoinDto(null, "plan-annual", good)); + Invoice second = People.tx(() -> invoices.findByAccountOrderByCreatedAtDesc(account)).get(0); + assertTrue(second.getCreditCents() > 0, "credit for the unused month"); + assertEquals(Billing.PAID, second.getStatus()); + } + + @Test + void aDeclinedJoinLeavesNoMembership() throws Exception { + String account = signed("Declined"); + String bad = card(account, FAILS_LATER); + ResponseStatusException declined = assertThrows(ResponseStatusException.class, + () -> billing.join(account, new JoinDto(null, "plan-unlimited", bad))); + assertEquals(402, declined.getStatus()); + List held = billing.memberships(account); + assertEquals(1, held.size()); + assertEquals(Billing.CANCELLED, held.get(0).state()); + assertEquals(Billing.VOID, People.tx(() -> invoices.findByAccountOrderByCreatedAtDesc(account)).get(0) + .getStatus()); + } + + @Test + void aFailingRenewalClimbsTheLadderAndIsPaidByHand() throws Exception { + String account = signed("Ladder"); + card(account, FAILS_LATER); + Member self = People.self(members, account); + Membership row = People.membership(account, self.getId(), "plan-unlimited", 0); + row.setCurrentPeriodEnd(Instant.now().minusMillis(60000L)); + People.tx(() -> { memberships.insert(row); return null; }); + + String bill = billing.renew(row.getId(), Instant.now()); + assertTrue(bill != null); + billing.collect(bill, false); + Invoice first = People.tx(() -> invoices.findById(bill)); + assertEquals(Billing.FAILED, first.getStatus()); + assertEquals(1, first.getAttempts()); + long wait = first.getNextAttemptAt().toEpochMilli() - System.currentTimeMillis(); + assertTrue(wait > DAY - 60000L && wait <= DAY, "a day to the first retry"); + // One failure is a grace period: the member can still come in. + assertEquals(Billing.ACTIVE, People.tx(() -> memberships.findById(row.getId())).getState()); + + // Renewing again does not bill the same period twice. + assertNull(billing.renew(row.getId(), Instant.now())); + + billing.collect(bill, false); + assertEquals(Billing.PAST_DUE, People.tx(() -> memberships.findById(row.getId())).getState()); + billing.collect(bill, false); + billing.collect(bill, false); + Invoice last = People.tx(() -> invoices.findById(bill)); + assertEquals(4, last.getAttempts()); + assertEquals(Billing.NEVER, last.getNextAttemptAt(), "no more retries"); + + String good = card(account, GOOD); + billing.pay(account, bill, good); + assertEquals(Billing.PAID, People.tx(() -> invoices.findById(bill)).getStatus()); + assertEquals(Billing.ACTIVE, People.tx(() -> memberships.findById(row.getId())).getState()); + } + + @Test + void aFrozenMembershipIsNotBilledAndThawsLonger() throws Exception { + String account = signed("Frozen"); + card(account, GOOD); + Member self = People.self(members, account); + Membership row = People.membership(account, self.getId(), "plan-unlimited", 0); + Instant now = Instant.now(); + row.setState(Billing.FROZEN); + row.setFrozenFrom(now.minusMillis(10L * DAY)); + row.setFrozenUntil(now.plusMillis(5L * DAY)); + row.setCurrentPeriodEnd(now.minusMillis(60000L)); + People.tx(() -> { memberships.insert(row); return null; }); + assertNull(billing.renew(row.getId(), now)); + assertTrue(People.tx(() -> invoices.findByAccountOrderByCreatedAtDesc(account)).isEmpty()); + + MembershipDto thawed = billing.unfreeze(account, row.getId()); + assertEquals(Billing.ACTIVE, thawed.state()); + // Ten frozen days are added to the period. + assertTrue(thawed.currentPeriodEnd() > now.plusMillis(9L * DAY).toEpochMilli()); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/EngagementTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/EngagementTest.java new file mode 100644 index 00000000000..06a2f858c44 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/EngagementTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.pulse.People; +import com.codenameone.examples.pulse.account.Accounts; +import com.codenameone.examples.pulse.api.AwayDto; +import com.codenameone.examples.pulse.domain.ClassSession; +import com.codenameone.examples.pulse.domain.Membership; +import com.codenameone.examples.pulse.domain.Preference; +import com.codenameone.examples.pulse.repository.ClassSessionRepository; +import com.codenameone.examples.pulse.repository.MemberRepository; +import com.codenameone.examples.pulse.repository.MembershipRepository; +import com.codenameone.examples.pulse.repository.NudgeRepository; +import com.codenameone.examples.pulse.repository.PreferenceRepository; +import com.codenameone.examples.pulse.repository.RoomRepository; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// What reaches a member and what does not: nothing marketing while they +/// are away, nothing twice, and billing frozen when they ask. +@BackendTest +class EngagementTest { + @Autowired + private Accounts accounts; + @Autowired + private MemberRepository members; + @Autowired + private MembershipRepository memberships; + @Autowired + private RoomRepository rooms; + @Autowired + private ClassSessionRepository sessions; + @Autowired + private PreferenceRepository preferences; + @Autowired + private NudgeRepository nudges; + @Autowired + private Engagement engagement; + @Autowired + private Nudger nudger; + + /// A member who takes marketing at any hour, so the test does not depend + /// on the time of day it runs at. + private String reachable(String name) throws Exception { + String account = People.member(accounts, members, memberships, name); + Preference row = new Preference(); + row.setUsername(account); + row.setTheme("system"); + row.setUnits("km"); + row.setNotifyClasses(true); + row.setNotifyPromotions(true); + row.setQuietFrom(0); + row.setQuietTo(0); + People.tx(() -> { + preferences.insert(row); + return null; + }); + return account; + } + + private static String today(int plus) { + return LocalDate.now(ZoneOffset.UTC).plusDays(plus).toString(); + } + + @Test + void someoneAwayIsNotAskedToFillAClass() throws Exception { + String trainer = People.trainer(accounts, "Coach"); + ClassSession session = People.session(sessions, "type-yoga", People.room(rooms, 20), + trainer, 20, System.currentTimeMillis() + 6L * 3600000L); + String here = reachable("Here"); + String away = reachable("Away"); + engagement.addAway(away, new AwayDto(null, today(0), today(3), "Travel", false)); + Instant now = Instant.now(); + assertFalse(nudger.offer(away, session.getId(), now)); + assertTrue(nudger.offer(here, session.getId(), now)); + // Once per class, and not again within two days for another class. + assertFalse(nudger.offer(here, session.getId(), now)); + assertTrue(People.tx(() -> nudges.findByAccountOrderBySentAtDesc(away)).isEmpty()); + assertEquals(1, People.tx(() -> nudges.findByAccountOrderBySentAtDesc(here)).size()); + } + + @Test + void someoneAwayIsNotToldTheyAreMissed() throws Exception { + String here = reachable("Missed"); + String away = reachable("Travelling"); + engagement.addAway(away, new AwayDto(null, today(0), today(20), "", false)); + Instant now = Instant.now(); + assertTrue(nudger.miss(here, now)); + assertFalse(nudger.miss(here, now), "not twice in three weeks"); + assertFalse(nudger.miss(away, now)); + } + + @Test + void awayWithBillingFrozenFreezesTheMembership() throws Exception { + String account = reachable("Freezer"); + AwayDto made = engagement.addAway(account, new AwayDto(null, today(0), today(13), + "Knee surgery", true)); + List held = People.tx(() -> memberships.findByAccountOrderByStartedAtDesc(account)); + assertEquals("FROZEN", held.get(0).getState()); + engagement.removeAway(account, made.id()); + assertEquals("ACTIVE", People.tx(() -> memberships.findByAccountOrderByStartedAtDesc(account)).get(0) + .getState()); + } +} diff --git a/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/PointsTest.java b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/PointsTest.java new file mode 100644 index 00000000000..528368b0485 --- /dev/null +++ b/scripts/pulse/backend/src/test/java/com/codenameone/examples/pulse/engagement/PointsTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.engagement; + +import com.codenameone.examples.pulse.domain.Workout; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PointsTest { + private static Workout workout(int minutes, int avgHr, String source, String zones) { + Workout row = new Workout(); + row.setMinutes(minutes); + row.setAvgHr(avgHr); + row.setSource(source); + row.setZones(zones); + return row; + } + + @Test + void aStrapIsWorthMoreThanAnEstimateAndAnEstimateMoreThanNothing() { + List none = new ArrayList(); + assertEquals(10 * 3 + 5 * 2, Points.score(3, 2, none)); + int bare = Points.score(0, 0, Arrays.asList(workout(45, 0, "HEALTH", "0,0,0,0,0"))); + int phone = Points.score(0, 0, Arrays.asList(workout(45, 130, "HEALTH", "5,5,5,5,0"))); + int strap = Points.score(0, 0, Arrays.asList(workout(45, 130, Points.STRAP, "5,5,5,5,0"))); + assertEquals(9, bare); + assertEquals(bare + Points.HEART_RATE, phone); + assertEquals(phone + Points.WORN_SENSOR, strap); + } + + @Test + void levelsClimbAndStopAtTheTop() { + assertEquals(1, Points.level(0)); + assertEquals(2, Points.level(100)); + assertEquals(100, Points.floor(2)); + assertEquals(250, Points.next(2)); + int top = Points.level(1000000); + assertEquals(Points.LEVELS.length, top); + assertEquals(Points.floor(top), Points.next(top)); + } + + @Test + void badgesComeFromTheRecord() { + List earned = Points.badges(12, 4, + Arrays.asList(workout(600, 150, Points.WATCH, "1,2,3,4,6"))); + assertEquals(Arrays.asList("FIRST_CLASS", "TEN_CLASSES", "STREAK_4", "HEART_TRACKED", + "STRAPPED_IN", "ZONE_5", "TEN_HOURS"), earned); + assertTrue(Points.badges(0, 0, new ArrayList()).isEmpty()); + assertEquals(0, Engagement.zoneMinutes(null, 5)); + assertEquals(6, Engagement.zoneMinutes("1,2,3,4,6", 5)); + assertEquals(0, Engagement.zoneMinutes("1,2", 5)); + } +} diff --git a/scripts/pulse/check-template.sh b/scripts/pulse/check-template.sh new file mode 100755 index 00000000000..2dfdc403587 --- /dev/null +++ b/scripts/pulse/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-template.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[pulse-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-template.py" pulse + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/PULSE-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/BookingApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/BookingApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/pulse/common/codenameone_settings.properties b/scripts/pulse/common/codenameone_settings.properties new file mode 100644 index 00000000000..ea9f1605c63 --- /dev/null +++ b/scripts/pulse/common/codenameone_settings.properties @@ -0,0 +1,55 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +codename1.arg.desktop.interactiveScrollbars=true +codename1.arg.desktop.titleBar=native +codename1.arg.ios.newStorageLocation=true +# What iOS shows when the front desk first scans a member's pass. +codename1.arg.ios.NSCameraUsageDescription=The camera scans members' passes at the front desk. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +codename1.arg.ios.locationUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.arg.nativeTheme=native +codename1.cssTheme=true +codename1.displayName=Pulse +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.pulse +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Pulse +codename1.watchMain=com.codenameone.examples.pulse.watch.PulseWatch +codename1.arg.ios.NSHealthShareUsageDescription=Pulse reads your recent workouts so your week at the studio includes them. +codename1.arg.ios.NSHealthUpdateUsageDescription=Pulse saves the classes you record as workouts. +codename1.arg.ios.NSBluetoothAlwaysUsageDescription=Pulse connects to your heart rate strap during a class. +# Heart rate only: Android has no workout record here; a recorded session is saved +# as its heart rate samples, so a workout permission would never be used. +codename1.arg.android.health.read=heart_rate +codename1.arg.android.health.write=heart_rate +codename1.arg.android.health.privacyPolicyUrl=https\://www.codenameone.com/privacy-policy.html +codename1.packageName=com.codenameone.examples.pulse +codename1.secondaryTitle=Hello World +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/pulse/common/icon.png b/scripts/pulse/common/icon.png new file mode 100644 index 00000000000..7b29fd80c8a Binary files /dev/null and b/scripts/pulse/common/icon.png differ diff --git a/scripts/pulse/common/pom.xml b/scripts/pulse/common/pom.xml new file mode 100644 index 00000000000..c3d4e111506 --- /dev/null +++ b/scripts/pulse/common/pom.xml @@ -0,0 +1,1375 @@ + + + 4.0.0 + + com.codenameone.examples.pulse + pulse + 1.0-SNAPSHOT + + com.codenameone.examples.pulse + pulse-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + ${project.groupId} + pulse-shared + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/pulse/common/src/desktop/resources/NativeTheme.res b/scripts/pulse/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/pulse/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/pulse/common/src/main/css/theme.css b/scripts/pulse/common/src/main/css/theme.css new file mode 100644 index 00000000000..6c4535d9801 --- /dev/null +++ b/scripts/pulse/common/src/main/css/theme.css @@ -0,0 +1,1899 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of the app. + * + * COLOURS AND SHAPE -- change these and nothing else to re-skin the app. Every + * rule below takes its colours from here; none names a colour of its own. Each + * colour has a second value for dark mode, used by the block at the end of the + * file. + */ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + /* ONE TYPE SCALE, in millimetres as a phone draws them: 2.1 caption, + 2.5 secondary, 2.75 body, 2.9 control, 3.5 heading, 4.4 title, 7 display. + A desktop draws a millimetre in far fewer pixels and is read from + further away, so every size is a quarter larger there. */ + device-desktop-font-scale: "1.25"; + /* A line too long for its place ends in an ellipsis, not mid-word. */ + endsWith3PointsBool: true; + /* Screens change quickly: nothing waits for an animation. */ + transitionSpeedInt: "200"; + /* The switch, sized from the Switch style's font. */ + switchThumbScaleY: "1.5"; + switchTrackScaleY: "1.8"; + switchTrackScaleX: "3.1"; + switchThumbInsetMM: "0.35"; + switchTrackOffOutlineWidthMM: "0"; + switchTrackOnOutlineWidthMM: "0"; + switchThumbWidthScale: "1.0"; + /* A deep orange-red that white lettering reads on at 4.6:1. */ + --brand: #db3416; + --brand-pressed: #f0532f; + --brand-contrast: #ffffff; + /* The studio at night: the hero cards, and the accents only they use -- + lime for points and effort, violet for levels and badges. */ + --ink: #17111f; + --ink-raised: #261d33; + --ink-text: #ffffff; + --ink-muted: #b9aecb; + --lime: #c6f432; + --violet: #7b4dff; + --violet-tint: #efe9ff; + --locked: #d9d3e0; + --surface: #ffffff; + --surface-alt: #f3f1f5; + --surface-press: #e6e2ea; + --text: #16121c; + /* A violet grey, dark enough to read on white. */ + --text-muted: #675f72; + --line: #e1dce6; + --line-strong: #c8c0d1; + --success: #12805c; + --warning: #b25e09; + --danger: #c5292a; + /* The pale ground a selected card and a badge sit on. */ + --brand-tint: #fff0ea; + --success-tint: #e4f4ed; + --warning-tint: #fcf0df; + --danger-tint: #fcebeb; + + --brand-dark: #ff5f3d; + --brand-pressed-dark: #ff7c5f; + --brand-contrast-dark: #1c0800; + --ink-dark: #221a2e; + --ink-raised-dark: #30263f; + --violet-dark: #a487ff; + --violet-tint-dark: #2a2142; + --locked-dark: #3a3147; + --surface-dark: #120f17; + --surface-alt-dark: #1f1a27; + --surface-press-dark: #352d40; + --text-dark: #efeaf5; + --text-muted-dark: #b6aec2; + --line-dark: #352d40; + --line-strong-dark: #4b4159; + --success-dark: #47c79a; + --warning-dark: #f0a24b; + --danger-dark: #ff7a7a; + --brand-tint-dark: #3a1a0e; + --success-tint-dark: #15302a; + --warning-tint-dark: #33270f; + --danger-tint-dark: #3a1f22; + + /* ONE RADIUS for anything card- or button-shaped, a smaller one for what + sits inside a card, and the sheet's. */ + --radius: 2mm; + --radius-small: 1.5mm; + + /* What floats over the map: the shadow under it, the dimming behind an + open menu, and the corners of the sheet. */ + --shadow: #101828; + /* Under a card with square-ish corners. Black, and without a blur where it + is used: that is the one shadow a rounded rectangle draws for itself. + Any other is rendered to pictures of the card when the theme is + compiled, and a build that cannot render them has no card at all. */ + --shadow-card: rgba(0, 0, 0, 0.14); + --scrim: #000000; + --radius-sheet: 4mm; +} + +/* ---------------------------------------------------------------- surfaces */ + +SvForm { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +SvDock { + background-color: var(--surface); + border-top: 1px solid var(--line); + padding: 2.4mm 3.6mm 3mm 3.6mm; + margin: 0; +} + +SvPage { + background-color: var(--surface); + padding: 3mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* The coloured top of the welcome screen. */ +SvHero { + background-color: var(--brand); + padding: 14mm 4.4mm 8mm 4.4mm; + margin: 0; +} + +SvHeroTitle { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 7mm; + padding: 2.4mm 0 0.8mm 0; + margin: 0; + background-color: transparent; +} + +SvHeroText { + color: var(--brand-contrast); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvHeroIcon { + color: var(--brand-contrast); + padding: 0; + margin: 0; + background-color: transparent; +} + +/* The panel that rises over the map. */ +SvCard { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 3.6mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* A panel that floats over the map with a gap around it. */ +SvFloat { + background-color: var(--surface); + border-radius: var(--radius); + padding: 2.4mm 3.2mm 2.4mm 3.2mm; + margin: 2.4mm; +} + +SvPanel { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.8mm 3mm 2.8mm 3mm; + margin: 1.2mm 0 1.2mm 0; +} + +SvDivider { + background-color: var(--line); + padding: 0; + margin: 2mm 0 2mm 0; +} + +/* -------------------------------------------------------------------- text */ + +SvTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0.6mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +SvHeading { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.75mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvMuted { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvLabel { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.6mm 0 1mm 0.3mm; + margin: 0; + background-color: transparent; +} + +SvPrice { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0; + margin: 0; + text-align: right; + background-color: transparent; +} + +/* The same, where it is the headline of a sheet: it starts where the + sheet's words start. */ + +SvError { + color: var(--danger); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +SvNotice { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2mm 0 1mm 0; +} + +/* ----------------------------------------------------------------- buttons */ + +SvPrimary { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvPrimary.pressed { + background-color: var(--brand-pressed); +} + +SvPrimary.disabled { + color: var(--text-muted); + background-color: var(--surface-press); +} + +SvSecondary { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvSecondary.pressed { + background-color: var(--surface-press); +} + +SvDanger { + color: var(--danger); + background-color: var(--danger-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvDanger.pressed { + background-color: var(--surface-press); +} + +SvLink { + color: var(--brand); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: center; + padding: 2.4mm 2mm 2.4mm 2mm; + margin: 0; +} + +SvSecondary.disabled, SvDanger.disabled, SvLink.disabled { + color: var(--line-strong); +} + +SvLink.pressed { + color: var(--brand-pressed); + background-color: var(--brand-tint); +} + +/* The round buttons that sit on the map. */ +SvMapButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.5mm; + cn1-box-shadow-blur: 1.6mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + padding: 2.4mm; + margin: 1.2mm 2.4mm 1.2mm 2.4mm; +} + +SvMapButton.pressed { + background-color: var(--surface-alt); +} + +/* The same button on a bar: flat, because nothing on a bar floats. */ +SvBarButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + padding: 2.4mm; + margin: 0.6mm 0.6mm 0.6mm 1.2mm; +} + +SvBarButton.pressed { + background-color: var(--surface-alt); +} + +/* "Where to?" -- looks like a field, behaves like a button. */ + +/* ------------------------------------------------------------------ fields */ + +SvField { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; +} + +/* The field being typed in says so. */ +SvField.selected { + background-color: var(--brand-tint); +} + +/* A search field over a row of chips: the gap keeps the chips off it. */ +SvSearchField { + cn1-derive: SvField; + margin: 0 0 2.4mm 0; +} +SvSearchField.selected { + background-color: var(--brand-tint); +} + +SvField.disabled { + color: var(--text-muted); +} + +SvFieldHint { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------- lists */ + +SvRow { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 2.6mm 0 2.6mm 0; + margin: 0; +} + +SvRow.pressed { + background-color: var(--surface-alt); +} + +SvRowIcon { + color: var(--text-muted); + padding: 0 3mm 0 0.4mm; + margin: 0; + background-color: transparent; +} + +SvRowTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvRowDetail { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +SvRowValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------ badges */ + +SvBadge { + color: var(--text-muted); + background: cn1-pill-border; + background-color: var(--surface-alt); + font-family: "native:MainBold"; + font-size: 2.05mm; + padding: 0.7mm 2mm 0.7mm 2mm; + margin: 0 0 0 2mm; +} + +SvBadgeGood { + cn1-derive: SvBadge; + color: var(--success); + background-color: var(--success-tint); +} + +SvBadgeWarn { + cn1-derive: SvBadge; + color: var(--warning); + background-color: var(--warning-tint); +} + +SvBadgeBad { + cn1-derive: SvBadge; + color: var(--danger); + background-color: var(--danger-tint); +} + +/* ------------------------------------------------------------------- admin */ + +SvStat { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.6mm 2.6mm 2.6mm 2.6mm; + margin: 0.8mm; +} + +SvStatValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvStatLabel { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +/* --------------------------------------------------------------- title bar */ + +/* The strip the clock and the battery are drawn over. */ +StatusBar { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +Toolbar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 0 0.6mm 0; + margin: 0; +} + +Title { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.05mm; + background-color: transparent; +} + +TitleCommand { + color: var(--text); + background-color: transparent; +} + +BackCommand { + color: var(--text); + background-color: transparent; +} + +/* The switch: the thumb is the colour, the track the background. */ +Switch { + color: #ffffff; + background-color: var(--line-strong); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.6mm 0 0.6mm 2mm; + margin: 0; +} + +Switch.selected { + color: #ffffff; + background-color: var(--brand); +} + +Switch.disabled { + color: var(--surface-alt); + background-color: var(--line); +} + +/* --------------------------------------------------------------------------- + * OVER THE MAP. The clear strip the status bar is drawn in, which the map + * runs under, the bar of floating controls, + * the sheet at the bottom and what goes in each. + */ + +/* "Where to?" -- a card that floats over the map and behaves like a button. */ + +/* From where to where, once there is somewhere to go. */ + +/* One short fact, in a pill under the bar. */ +SvChip { + color: var(--text); + background: cn1-pill-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.4mm; + cn1-box-shadow-blur: 1.4mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + font-family: "native:MainBold"; + font-size: 2.4mm; + padding: 1.4mm 3mm 1.4mm 2.6mm; + margin: 1.2mm 2mm 1mm 2mm; +} + +SvSheet { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 0.4mm 3.6mm 3.2mm 3.6mm; + margin: 0; +} + +/* The handle of the sheet: the colour is the bar's. */ +SvGrip { + color: var(--line-strong); + background-color: transparent; + padding: 1.6mm 0 1.8mm 0; + margin: 0; +} + +/* The countdown on the pass card: lime for the time left, as on the watch, + since red would read as an error. */ +SvRingTrack { + color: var(--ink-raised); + background-color: transparent; +} + +SvRing { + color: var(--lime); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + padding: 0.8mm; + margin: 0 0 0 2.4mm; +} + +SvPulse { + color: var(--brand); + background-color: transparent; + padding: 0.8mm; + margin: 0.8mm 0 0.8mm 0; +} + +/* Home and Work, side by side on the idle sheet. */ +SvShortcut { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 2mm 0.8mm 0.8mm 0.8mm; +} + +SvShortcut.pressed { + background-color: var(--surface-press); +} + +SvAvatar { + color: var(--brand-contrast); + background: cn1-round-border; + background-color: var(--brand); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.3mm; + margin: 0 3mm 0 0; +} + +/* A number plate reads as one, so it is boxed like one. */ + +SvActions { + background-color: transparent; + padding: 0; + margin: 0.8mm 0 0 0; +} + +/* Call, message, share: an icon with a word under it. */ +SvAction { + color: var(--text); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.2mm; + text-align: center; + padding: 1.8mm 0 1.8mm 0; + margin: 0; +} + +SvAction.pressed { + color: var(--brand); + background-color: var(--surface-alt); +} + +/* The desk's two ways to let someone in: big tiles, scanning filled. */ +SvFrontScan, SvFrontScan.pressed { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 5mm 1mm 4.2mm 1mm; + margin: 0 1.2mm 0 0; +} + +SvFrontScan.pressed { + background-color: var(--brand-pressed); +} + +SvFrontFob, SvFrontFob.pressed { + color: var(--text); + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 3mm; + text-align: center; + padding: 5mm 1mm 4.2mm 1mm; + margin: 0 0 0 1.2mm; +} + +SvFrontFob.pressed { + background-color: var(--surface-alt); +} + +SvActionDanger { + cn1-derive: SvAction; + color: var(--danger); +} + +SvActionDanger.pressed { + color: var(--danger); + background-color: var(--danger-tint); +} + +/* ------------------------------------------------------ records and charts */ + +/* A list that fills a screen: rows from edge to edge, each with the page's + margin inside it. */ +SvList { + background-color: var(--surface); + padding: 0 3.6mm 3.2mm 3.6mm; + margin: 0; +} +/* One line of a receipt: what, and how much. */ +SvFact { + background-color: transparent; + padding: 1.1mm 0 1.1mm 0; + margin: 0; +} +SvFactLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + padding: 0; + margin: 0; +} +SvFactValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: right; + padding: 0 0 0 3mm; + margin: 0; +} +/* The line the others add up to. */ +SvFactTotal { + cn1-derive: SvFactValue; + font-family: "native:MainBold"; + font-size: 3.3mm; +} +/* The one thing a screen is about, said large at its top. */ +/* A note to read in the flow of a page: see Ui.callout. */ +SvCallout { + background-color: var(--warning-tint); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 2.4mm; + margin: 2.4mm 0 1mm 0; +} +SvCalloutIcon { + color: var(--warning); + background-color: transparent; + padding: 0.3mm 2.2mm 0 0; + margin: 0; +} +SvCalloutTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.5mm; + padding: 0; + margin: 0; +} +SvCalloutText { background-color: transparent; + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + padding: 0.4mm 0 0 0; + margin: 0; +} + +SvBanner { + background-color: transparent; + padding: 5mm 3mm 3mm 3mm; + margin: 0; +} +SvBannerIcon { + color: var(--brand); + background: cn1-round-border; + background-color: var(--brand-tint); + padding: 3.4mm; + margin: 0 0 2.4mm 0; +} +SvBannerIconGood { + cn1-derive: SvBannerIcon; + color: var(--success); + background-color: var(--success-tint); +} +SvBannerIconWarn { + cn1-derive: SvBannerIcon; + color: var(--warning); + background-color: var(--warning-tint); +} +SvBannerIconBad { + cn1-derive: SvBannerIcon; + color: var(--danger); + background-color: var(--danger-tint); +} +SvBannerTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: center; + padding: 0.4mm 0 0.4mm 0; + margin: 0; +} +SvBannerText { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 2mm 0.4mm 2mm; + margin: 0; +} +/* The amount a receipt is for. */ +SvAmount { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 7mm; + text-align: center; + padding: 1mm 0 0.4mm 0; + margin: 0; +} +/* A group of rows under one heading. */ +SvGroup { + background-color: transparent; + padding: 0; + margin: 0 0 0.8mm 0; +} +/* A chart or a breakdown, boxed with its title. */ +SvInkBpm { background-color: transparent; font-family: "native:MainBold"; font-size: 9mm; color: var(--lime); padding: 1mm 0 1mm 0; margin: 0; } +SvZoneBar { background-color: transparent; padding: 0; margin: 1.2mm 0 0 0; } +SvWorkoutWorn { background-color: transparent; color: var(--violet); padding: 0 3mm 0 0.4mm; margin: 0; } +SvPoints { font-family: "native:MainBold"; font-size: 2.9mm; color: var(--ink); background-color: var(--lime); border-radius: 3mm; padding: 1mm 2.4mm 1mm 2.4mm; margin: 0; } +SvAvatarLines { background-color: transparent; padding: 0 0 0 2.6mm; margin: 0; } +SvKudos { font-family: "native:MainBold"; font-size: 2.8mm; color: var(--brand); background-color: var(--surface-alt); border-radius: 4mm; padding: 1.2mm 2.6mm 1.2mm 2.4mm; margin: 0; } +SvKudos.pressed { background-color: var(--surface-press); } +SvKudos.disabled { color: var(--text-muted); background-color: var(--surface-alt); } +SvKudosGiven, SvKudosGiven.disabled { font-family: "native:MainBold"; font-size: 2.8mm; color: #ffffff; background-color: var(--brand); border-radius: 4mm; padding: 1.2mm 2.6mm 1.2mm 2.4mm; margin: 0; } +SvKudosMine, SvKudosMine.disabled { font-family: "native:MainBold"; font-size: 2.8mm; color: var(--text-muted); background-color: transparent; padding: 1.2mm 2.6mm 1.2mm 2.4mm; margin: 0; } +SvPassQr { background-color: transparent; padding: 0; margin: 0; } +SvPassPanel { background-color: #ffffff; border-radius: 3mm; padding: 3mm; margin: 3mm 0 2mm 0; } +SvPassCode { background-color: transparent; font-family: "native:MainBold"; font-size: 7mm; color: var(--lime); padding: 0; margin: 0; } +SvTile { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 3mm 3mm 3mm 3mm; + margin: 0.8mm 0.8mm 1.6mm 0.8mm; +} +/* A bar chart: the colour is the bars'. */ +SvChart { + color: var(--brand); + background-color: transparent; + padding: 1.6mm 0 0 0; + margin: 0; +} +SvChartPeak { + color: var(--brand-pressed); + background-color: transparent; +} +SvChartGrid { + color: var(--line); + background-color: transparent; +} +/* The owner's popular classes: a name and count, a bar under it. */ +SvRankRow { + background-color: transparent; + padding: 2mm 0 0.8mm 0; + margin: 0; +} + +/* An action on the trailing edge of a row: offering a shift, taking one. */ +SvRowAction { + font-family: "native:MainBold"; + font-size: 2.6mm; + color: var(--brand); + background-color: var(--brand-tint); + border-radius: 4mm; + padding: 1.4mm 2.8mm 1.4mm 2.8mm; + margin: 0; + text-align: center; +} +SvRowAction.pressed { + background-color: var(--surface-alt); +} +SvRowActionQuiet { + cn1-derive: SvRowAction; + color: var(--text-muted); + background-color: var(--surface-alt); +} + +/* A coach's place on the leaderboard; first place in the brand colour. */ +SvRankPlace { + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.4mm; + color: var(--text-muted); + padding: 0 2.4mm 0 0; + margin: 0; +} +SvRankFirst { + cn1-derive: SvRankPlace; + color: var(--brand); +} + +SvRankBar { + color: var(--surface-alt); + background-color: transparent; + padding: 0; + margin: 0 0 1.2mm 0; +} + +SvChartTitle { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.3mm; + padding: 2.4mm 0 1mm 0; + margin: 0; +} + +SvChartAxis { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2mm; + padding: 0.8mm 0 0 0; + margin: 0; +} +/* The number a chart adds up to, over the chart. */ +SvChartValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 4.2mm; + text-align: left; + padding: 0; + margin: 0; +} +/* A share along a track: the colour is the share, the background the track. */ +SvMeter { + color: var(--brand); + background-color: transparent; + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} +/* Something waiting for the admin, at the top of the dashboard. */ +SvAlert { + color: var(--warning); + background-color: var(--warning-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 0.8mm; +} +SvAlert.pressed { + background-color: var(--surface-press); +} + +/* ----------------------------------------------------------------- choosing */ + +/* One of several things to pick from, of which one is picked: a kind of car, + a way to pay. */ +SvOption { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 0.8mm 0 0.8mm 0; +} +SvOption.pressed { + background-color: var(--surface-alt); +} +SvOptionPicked { + cn1-derive: SvOption; + background-color: var(--brand-tint); + border: 2px solid var(--brand); +} +SvOptionIcon { + color: var(--text); + background-color: transparent; + padding: 0 2.4mm 0 0; + margin: 0; +} +/* A payment card, drawn as one. */ +SvCardFace { + background-color: var(--brand); + border-radius: var(--radius); + padding: 4mm 4mm 3.4mm 4mm; + margin: 1mm 0 2.4mm 0; +} +SvCardBrand { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: right; + padding: 0; + margin: 0; +} +SvCardNumber { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.7mm; + text-align: left; + padding: 4.8mm 0 2.4mm 0; + margin: 0; +} +SvCardSmall { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0; + margin: 0; +} +/* Two fields side by side: the space between them is theirs. */ +SvPairLeft { + background-color: transparent; + padding: 0 1.2mm 0 0; + margin: 0; +} +SvPairRight { + background-color: transparent; + padding: 0 0 0 1.2mm; + margin: 0; +} +/* A picture of a document, small, beside what it is. */ +SvThumb { + color: var(--text-muted); + background-color: var(--surface-alt); + border-radius: var(--radius-small); + padding: 2mm; + margin: 0 3mm 0 0; +} +/* The same picture, filling the screen. */ +SvPicture { + background-color: var(--scrim); + padding: 0; + margin: 0; +} +/* How far through the steps of a form: "Step 2 of 5". */ +/* What is fixed to the foot of a form of several steps. */ +SvFooter { + background-color: var(--surface); + border-top: 1px solid var(--line); + padding: 1mm 3.6mm 1.6mm 3.6mm; + margin: 0; +} + +/* ---------------------------------------------------------------- the menu */ + +/* Dimmed by the colour's own alpha. `opacity` would not do it: a style's + opacity fades what a component draws over its background, and the + background itself is filled at the background's transparency -- so an opaque + black with an opacity is still a black screen. */ +SvScrim { + background-color: rgba(0, 0, 0, 0.45); + padding: 0; + margin: 0; +} + +SvMenu { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +SvMenuTop { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 5mm 4mm 3.6mm 4mm; + margin: 0; +} + +SvMenuName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: left; + padding: 0; + margin: 0; +} + +SvMenuDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.4mm; + text-align: left; + padding: 0.2mm 0 0 0; + margin: 0; +} + +/* Which of the three apps this is. */ +SvModeBadge { + color: var(--brand); + background: cn1-pill-border; + background-color: var(--brand-tint); + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: center; + padding: 0.8mm 2.4mm 0.8mm 2.4mm; + margin: 2.4mm 0 0 0; +} + +SvMenuLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: left; + padding: 3.6mm 4mm 1mm 4mm; + margin: 0; +} + +SvMenuItem { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.9mm; + text-align: left; + padding: 2.6mm 4mm 2.6mm 4mm; + margin: 0; +} + +SvMenuItem.pressed { + background-color: var(--surface-alt); +} + +/* The right half of a page of figures on a wide window, set off from the left. */ +SvBroadRight { + background-color: transparent; + padding: 0 0 0 4mm; + margin: 0; +} + +/* The menu docked down the side of a wide window. */ +SvMenuDock { + background-color: var(--surface); + border-right: 1px solid var(--line); + padding: 0; + margin: 0; +} + +/* The place that is open, in the docked menu. */ +SvMenuItemPicked { + color: var(--brand); + background-color: var(--brand-tint); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: left; + padding: 2.6mm 4mm 2.6mm 4mm; + margin: 0; +} + +SvMenuBottom { + background-color: transparent; + border-top: 1px solid var(--line); + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} + +/* The top of a home screen that has no map: the menu, and what this is. */ +SvHomeBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 3.6mm 0.6mm 0; + margin: 0; +} + +/* ---------------------------------------------------------------- settings */ + +SvSection { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 5mm 0 0.8mm 0; + margin: 0; +} + +/* A few options side by side, one of them chosen. */ +SvSegments { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 0.5mm; + margin: 0.8mm 0 1.6mm 0; +} + +SvSegment { + color: var(--text-muted); + background-color: transparent; + border-radius: 1.9mm; + font-family: "native:MainBold"; + font-size: 2.3mm; + text-align: center; + padding: 2mm 1.2mm 2mm 1.2mm; + margin: 0.3mm; +} + +/* ".selected" is the FOCUSED state in a Codename One theme, not the chosen + one: a chosen toggle wears ".pressed". Styled like the chosen option, the + focused one looked chosen too, so two options of one row seemed picked. */ +SvSegment.selected { + color: var(--text-muted); + background-color: transparent; +} + +/* A toggle that is on wears the pressed style, so the two say the same. */ +SvSegment.pressed { + color: var(--text); + background-color: var(--surface); +} + +SvSegment.disabled { + color: var(--line-strong); +} + +/* The outline of rows that are on their way: the colour is the bars'. */ +SvSkeleton { + color: var(--surface-alt); + background-color: transparent; + padding: 1mm 0 0 0; + margin: 0; +} + +/* "Pull to refresh", over a list being pulled. */ +PullToRefresh { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + text-align: center; + padding: 1.6mm; + margin: 0; +} + +/* What the app says in passing, over the foot of the screen. */ +ToastBar { + background-color: var(--text); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2.4mm 3.6mm 3.2mm 3.6mm; +} + +ToastBarMessage { + color: var(--surface); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0; + margin: 0; +} + +/* ------------------------------------------------- the admin console, wide */ + +/* On a tablet on its side, a desktop or a browser the console is three + columns: where to go, a list, and what was picked from it. */ +SvSide { + background-color: var(--surface-alt); + border-right: 1px solid var(--line); + padding: 2.4mm 1.6mm 2.4mm 1.6mm; + margin: 0; +} + +SvSideName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.05mm; + padding: 0.8mm 2.4mm 0 2.4mm; + margin: 0; +} + +SvSideDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0 2.4mm 2.4mm 2.4mm; + margin: 0; +} + +SvSideLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.15mm; + padding: 3.2mm 2.4mm 0.8mm 2.4mm; + margin: 0; +} + +SvSideItem { + color: var(--text); + background-color: transparent; + border-radius: var(--radius-small); + font-family: "native:MainRegular"; + font-size: 2.8mm; + text-align: left; + padding: 1.9mm 2.4mm 1.9mm 2.4mm; + margin: 0.2mm 0 0.2mm 0; +} + +SvSideItem.pressed { + background-color: var(--line); +} + +SvSidePicked { + cn1-derive: SvSideItem; + color: var(--brand-contrast); + background-color: var(--brand); + font-family: "native:MainBold"; +} + +SvMaster { + background-color: var(--surface); + border-right: 1px solid var(--line); + padding: 0; + margin: 0; +} + +SvPaneBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 1.2mm 4mm 1.2mm 4mm; + margin: 0; +} + +SvPaneTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 1.6mm 0 1.6mm 0; + margin: 0; +} + +/* The row of a list whose detail is open beside it. */ +SvRowPicked { + cn1-derive: SvRow; + background-color: var(--surface-alt); +} + +/* + * THE DESKTOP'S OWN LOOK. On macOS, Windows and Linux, and in a desktop + * browser, the platform's theme is under this one (the nativeTheme=native build + * hint), and the admin console wears it: Layouts.dress() gives each component + * the SvDesk twin of its style. A twin derives from the platform's control + * where there is one -- a button, a field, a segmented control, a group box -- + * and takes its colours from the palette every desktop theme declares, so the + * same rules are Aqua on a Mac, Fluent on Windows and Adwaita on Linux. Nothing + * here names the brand; the accent is the platform's. + * + * The values in SvDeskPalette are only what the compiler needs to resolve + * var(): each use is bound to the name, and the installed theme's own value + * replaces it when the app runs. They are declared outside #Constants so that + * they are not exported over the theme's. + */ +SvDeskPalette { + --window-bg-color: #ececec; + --window-bg-color-dark: #1e1e1e; + --view-bg-color: #ffffff; + --view-bg-color-dark: #1e1e1e; + --control-bg-color: #ffffff; + --control-bg-color-dark: #2c2c2c; + --headerbar-bg-color: #e8e8e8; + --headerbar-bg-color-dark: #2a2a2a; + --text-color: #1a1a1a; + --text-color-dark: #ffffff; + --text-secondary-color: #5d5d5d; + --text-secondary-color-dark: #c0c0c0; + --separator-color: #d8d8d8; + --separator-color-dark: #3a3a3a; + --accent-color: #007aff; + --accent-color-dark: #0a84ff; + --accent-fg-color: #ffffff; + --accent-fg-color-dark: #ffffff; + background-color: transparent; + padding: 0; + margin: 0; +} + +SvDeskForm { background-color: var(--window-bg-color); padding: 0; margin: 0; } +SvDeskPage { background-color: var(--window-bg-color); padding: 3mm 5mm 4mm 5mm; margin: 0; } +SvDeskList { background-color: var(--view-bg-color); padding: 0 3mm 2mm 3mm; margin: 0; } +SvDeskMaster { background-color: var(--view-bg-color); border-right: 1px solid #c6c6c8; padding: 0; margin: 0; } +SvDeskHomeBar { background-color: var(--headerbar-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 4mm 1mm 0; margin: 0; } +SvDeskPaneBar { background-color: var(--window-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 5mm 1mm 5mm; margin: 0; } +SvDeskPaneTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 1.6mm 0 1.6mm 0; margin: 0; } +SvDeskBarButton { cn1-derive: Button; padding: 1.2mm; margin: 0 2mm 0 0; } + +SvDeskSide { background-color: var(--headerbar-bg-color); border-right: 1px solid #c6c6c8; padding: 2.5mm 2mm 2.5mm 2mm; margin: 0; } +SvDeskSideName { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; padding: 1mm 2.6mm 0 2.6mm; margin: 0; } +SvDeskSideDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.25mm; padding: 0 2.6mm 2mm 2.6mm; margin: 0; } +SvDeskSideLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.15mm; padding: 3mm 2.6mm 1mm 2.6mm; margin: 0; } +SvDeskSideItem { color: var(--text-color); background-color: transparent; border-radius: 1.4mm; font-family: "native:MainRegular"; font-size: 2.65mm; text-align: left; padding: 1.6mm 2.6mm 1.6mm 2.6mm; margin: 0.2mm 0 0.2mm 0; } +SvDeskSideItem.pressed { background-color: var(--separator-color); } +SvDeskSidePicked { cn1-derive: SvDeskSideItem; color: var(--accent-fg-color); background-color: var(--accent-color); } + +SvDeskPrimary { cn1-derive: RaisedButton; text-align: center; margin: 2mm 0 1mm 0; } +SvDeskSecondary { cn1-derive: Button; text-align: center; margin: 1.5mm 0 1mm 0; } +SvDeskDanger { cn1-derive: Button; color: var(--danger); text-align: center; margin: 1.5mm 0 1mm 0; } +SvDeskLink { cn1-derive: Link; padding: 1.6mm 0 1.6mm 0; } +SvDeskField { cn1-derive: TextField; margin: 0; } +SvDeskFieldHint { cn1-derive: TextHint; } +SvDeskSegments { cn1-derive: TabsContainer; border-radius: 1.6mm; padding: 0.4mm; margin: 1mm 0 2mm 0; } +/* Four of these share the master pane of the console, which is narrow: the + platform's own tab padding leaves "Cancelled" no room and it was drawn cut + off at both ends. */ +SvDeskSegment { cn1-derive: Tab; text-align: center; padding: 1.4mm 0.4mm 1.4mm 0.4mm; margin: 0; } +/* Something waiting is the one thing on the console that is not the + platform's grey: it wears the warning tint the phone's does. */ +SvDeskAlert { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } +SvDeskAlert.pressed { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } + +SvDeskRow { background-color: transparent; border-bottom: 1px solid #d8d8d8; padding: 2mm 1mm 2mm 1mm; margin: 0; } +SvDeskRow.pressed { background-color: var(--separator-color); } +SvDeskRowPicked { background-color: var(--separator-color); border-bottom: 1px solid #d8d8d8; border-radius: 1.4mm; padding: 2mm 1mm 2mm 1mm; margin: 0; } +SvDeskRowIcon { color: var(--text-secondary-color); background-color: transparent; padding: 0 2.6mm 0 0; margin: 0; } +SvDeskRowTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.7mm; padding: 0; margin: 0; } +SvDeskRowDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.4mm 0 0 0; margin: 0; } +SvDeskRowValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.65mm; text-align: right; padding: 0; margin: 0; } + +SvDeskSection { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.8mm; text-align: left; padding: 4mm 0 1.2mm 0; margin: 0; } +SvDeskTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.4mm; padding: 0 0 2mm 0; margin: 0; } +SvDeskHeading { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +SvDeskText { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.65mm; padding: 1mm 0 1mm 0; margin: 0; } +SvDeskMuted { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 0.5mm 0 0.5mm 0; margin: 0; } +SvDeskLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 2.4mm 0 0.8mm 0; margin: 0; } +SvDeskError { color: var(--danger); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; padding: 1mm 0 1mm 0; margin: 0; } +SvDeskNotice { cn1-derive: GroupBox; color: var(--text-color); font-family: "native:MainRegular"; font-size: 2.55mm; padding: 2.4mm; margin: 2mm 0 1mm 0; } +SvDeskPanel { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1mm 0 1mm 0; } +SvDeskFact { background-color: transparent; padding: 1.2mm 0 1.2mm 0; margin: 0; } +SvDeskFactLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; padding: 0; margin: 0; } +SvDeskFactValue { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; text-align: right; padding: 0; margin: 0; } +SvDeskFactTotal { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; text-align: right; padding: 0; margin: 0; } +SvDeskAmount { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 6.4mm; text-align: center; padding: 2mm 0 1mm 0; margin: 0; } +SvDeskBannerIcon { color: var(--text-secondary-color); background-color: transparent; padding: 6mm 0 2mm 0; margin: 0; } +SvDeskBannerTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.05mm; text-align: center; padding: 0; margin: 0; } +SvDeskBannerText { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; text-align: center; padding: 1mm 0 0 0; margin: 0; } + +/* The numbers and the charts have no control on any desktop: they keep their + shape, in a group box, in the platform's colours. */ +SvDeskTile { cn1-derive: GroupBox; padding: 3mm 3.4mm 3mm 3.4mm; margin: 1.2mm; } +SvDeskStat { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1.2mm; } +SvDeskStatValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0; margin: 0; } +SvDeskStatLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.6mm 0 0 0; margin: 0; } +SvDeskChartValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0.6mm 0 0 0; margin: 0; } +SvDeskChartAxis { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.6mm 0 0 0; margin: 0; } + +/* + * DARK MODE. The same rules, with the second value of each colour. A rule added + * above that names a colour needs its line here. + */ +/* THE CUSTOMER'S SCREENS -- Grub's look: photos on cards, the green for what + can be tapped, grey for everything that is only read. */ +SvHomeHeader { background-color: var(--surface); padding: 1mm 3mm 1.5mm 1mm; margin: 0; } +SvGreeting { color: var(--text); font-family: "native:MainBold"; font-size: 4.4mm; padding: 1mm 0 0 0; margin: 0; background-color: transparent; } +SvMutedSmall { color: var(--text-muted); font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.3mm 0 0.3mm 0; margin: 0; background-color: transparent; } +SvMetaLine { color: var(--text); font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.3mm 0 0 0; margin: 0; background-color: transparent; } +SvTabBar { background-color: var(--surface); border-top: 1px solid var(--line); padding: 0.6mm 0 0.6mm 0; margin: 0; } +SvTab { color: var(--text-muted); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.05mm; text-align: center; padding: 1mm 0 1mm 0; margin: 0; } +SvTab.pressed { color: var(--text); } +SvTabSelected { color: var(--brand-pressed); background-color: transparent; font-family: "native:MainBold"; font-size: 2.05mm; text-align: center; padding: 1mm 0 1mm 0; margin: 0; } +SvSheetTitle { color: var(--text); font-family: "native:MainBold"; font-size: 3.5mm; padding: 0; margin: 0; background-color: transparent; } +SvTotal { color: var(--text); font-family: "native:MainBold"; font-size: 3.5mm; padding: 0; margin: 0; background-color: transparent; } + +/* THE KITCHEN'S QUEUE -- down the left on a wide screen, the order beside it. */ +SvQueuePane { background-color: var(--surface); border-right: 1px solid var(--line); padding: 0; margin: 0; } +SvDetailPane { background-color: var(--surface-alt); padding: 0; margin: 0; } +SvQueueRow { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2.6mm 3mm 2.6mm 3mm; margin: 0; } +SvQueueRow.pressed { background-color: var(--surface-alt); } +SvQueueRowPicked { background-color: var(--brand-tint); border-bottom: 1px solid var(--line); padding: 2.6mm 3mm 2.6mm 3mm; margin: 0; } + +/* The trainer's live view: a tile per member, coloured by heart rate zone, + from easy (1) to flat out (5); 0 is no reading yet. */ +SvZone0 { color: #ffffff; background-color: #5f6b76; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZone1 { color: #ffffff; background-color: #8e9aa6; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZone2 { color: #ffffff; background-color: #3a86ff; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZone3 { color: #ffffff; background-color: #2a9d55; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZone4 { color: #ffffff; background-color: #e09f00; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZone5 { color: #ffffff; background-color: #e4572e; border-radius: var(--radius); padding: 2mm; margin: 1mm; } +SvZoneName { color: #ffffff; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 0; margin: 0; background-color: transparent; } +SvZoneRate { color: #ffffff; font-family: "native:MainBold"; font-size: 5mm; padding: 0.5mm 0 0 0; margin: 0; background-color: transparent; } + +/* The watch app: large type on black. */ +SvLead { background-color: transparent; padding: 0; margin: 0; border: none; font-size: 0.1mm; } +SvWatchForm { background-color: #000000; padding: 0.5mm 0.5mm 2mm 0.5mm; } +SvWatchEyebrow { color: #c6f432; background-color: transparent; font-family: "native:MainBold"; font-size: 1.8mm; padding: 1mm 1mm 0 1mm; margin: 0; } +SvWatchTitle { color: #ffffff; background-color: transparent; font-family: "native:MainBold"; font-size: 3mm; padding: 0.3mm 1mm; margin: 0; } +SvWatchText { color: #b0b3b8; background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 0.2mm 1mm; margin: 0; } +SvWatchCard { background-color: #1a1a1f; border-radius: 2.5mm; padding: 0.8mm 0.5mm; margin: 1.2mm 1mm 0.8mm 1mm; } +SvWatchCode { color: #ffffff; background-color: transparent; font-family: "native:MainBold"; font-size: 3.4mm; text-align: center; padding: 0.5mm 0; margin: 0; } +SvWatchBar { background-color: transparent; padding: 0.5mm 1mm; margin: 0; } +SvWatchStat { color: #c6f432; background-color: transparent; font-family: "native:MainBold"; font-size: 2.2mm; text-align: center; padding: 1mm 1mm 0 1mm; margin: 0; } +SvWatchStreak { color: #b0b3b8; background-color: transparent; font-family: "native:MainRegular"; font-size: 2mm; text-align: center; padding: 0 1mm; margin: 0; } +SvWatchPage { background-color: #000000; padding: 0; margin: 0; } +SvWatchBig { color: #c6f432; background-color: transparent; font-family: "native:MainBold"; font-size: 4.2mm; padding: 0.3mm 1mm; margin: 0; } +SvWatchStanding { color: #c6f432; background-color: transparent; font-family: "native:MainBold"; font-size: 2mm; padding: 1mm 1mm 0 1mm; margin: 0; } +SvWatchSmall { color: #8a8d93; background-color: transparent; font-family: "native:MainRegular"; font-size: 1.9mm; padding: 0.2mm 1mm; margin: 0; } +SvWatchSmallCenter { color: #8a8d93; background-color: transparent; font-family: "native:MainRegular"; font-size: 1.9mm; text-align: center; padding: 0.3mm 1mm; margin: 0; } +SvWatchEyebrowMuted { color: #c6f432; background-color: transparent; font-family: "native:MainBold"; font-size: 1.7mm; padding: 0; margin: 0; } +SvWatchRow { background-color: #1a1a1f; border-radius: 2mm; padding: 0.8mm 1.2mm; margin: 0.6mm 0 0 0; } +SvWatchRowTitle { color: #ffffff; background-color: transparent; font-family: "native:MainBold"; font-size: 2.5mm; padding: 0.2mm 0; margin: 0; } +SvWatchRing { background-color: transparent; padding: 0.8mm 0 0.4mm 0; margin: 0; } +SvWatchBars { background-color: transparent; padding: 0.8mm 2mm 0.2mm 2mm; margin: 0; } +SvWatchDots { background-color: #000000; padding: 0; margin: 0; } + +/* ----------------------------------------------------------- class cards */ + +SvClass { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: 3mm; + padding: 2.4mm 2.4mm 2.4mm 0; + margin: 1.2mm 0 1.2mm 0; +} +SvClassTime { background-color: transparent; padding: 0 2mm 0 2.6mm; margin: 0; } +SvClassClock { background-color: transparent; font-family: "native:MainBold"; font-size: 3.6mm; color: var(--text); padding: 0; margin: 0; } +SvClassLength { background-color: transparent; font-size: 2.6mm; color: var(--text-muted); padding: 0; margin: 0; } +SvClassStripe { background-color: var(--brand); padding: 0 0.5mm 0 0.5mm; margin: 0.5mm 0 0.5mm 0; border-radius: 1mm; } +SvClassBody { background-color: transparent; padding: 0 1.6mm 0 2.4mm; margin: 0; } +SvClassName { background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; color: var(--text); padding: 0; margin: 0; } +SvClassGlyph { background-color: transparent; padding: 0 1.2mm 0 0; margin: 0; } +SvClassWho { background-color: transparent; font-size: 2.7mm; color: var(--text-muted); padding: 0.6mm 0 0.8mm 0; margin: 0; } +SvClassFill { background-color: transparent; padding: 0.4mm 0 0 0; margin: 0; } +SvClassLeft { background-color: transparent; font-size: 2.5mm; color: var(--text-muted); padding: 0 0 0 1.6mm; margin: 0; } +SvClassScarce { background-color: transparent; font-family: "native:MainBold"; font-size: 2.5mm; color: var(--brand); padding: 0 0 0 1.6mm; margin: 0; } +SvClassFull { background-color: transparent; font-family: "native:MainBold"; font-size: 2.5mm; color: var(--danger); padding: 0 0 0 1.6mm; margin: 0; } +SvClassFriends { background-color: transparent; font-size: 2.6mm; color: var(--violet); padding: 0.8mm 0 0 0; margin: 0; } +/* Tinted, not filled: a page of classes is a page of Book buttons, and a + column of solid red shouted. */ +SvPillBook { font-family: "native:MainBold"; font-size: 2.9mm; color: var(--brand); background-color: var(--brand-tint); border-radius: 4mm; padding: 1.4mm 2.8mm 1.4mm 2.8mm; margin: 0; text-align: center; } +SvPillBook.pressed { color: var(--brand-contrast); background-color: var(--brand); } +SvPillQueue { font-family: "native:MainBold"; font-size: 2.9mm; color: var(--violet); background-color: var(--violet-tint); border-radius: 4mm; padding: 1.4mm 2.8mm 1.4mm 2.8mm; margin: 0; text-align: center; } +SvPillBooked { font-family: "native:MainBold"; font-size: 2.9mm; color: var(--success); background-color: var(--success-tint); border-radius: 4mm; padding: 1.4mm 2.8mm 1.4mm 2.8mm; margin: 0; text-align: center; } + +/* Days of the week and people in a household: pills in a row that scrolls. + A chosen toggle wears ".pressed" (see SvSegment). */ +SvPills { background-color: transparent; padding: 0 0 2mm 0; margin: 0; } +SvPill { font-family: "native:MainBold"; font-size: 2.6mm; color: var(--text-muted); background-color: var(--surface-alt); border-radius: 4mm; padding: 1.8mm 3.2mm 1.8mm 3.2mm; margin: 0 1.6mm 0 0; text-align: center; } +SvPill.selected { color: var(--text-muted); background-color: var(--surface-alt); } +SvPill.pressed { color: var(--ink-text); background-color: var(--ink); } + +/* --------------------------------------------------------- gamification */ + +SvLevel { background-color: transparent; font-family: "native:MainBold"; font-size: 5mm; color: var(--lime); padding: 0; margin: 0; } +SvLevelTrack { color: var(--ink-raised); } +SvShelf { background-color: transparent; padding: 1mm 0 1mm 0; margin: 0; } +SvMedalCell { background-color: transparent; padding: 0 1.4mm 0 1.4mm; margin: 0; } +SvMedal { color: #ffffff; background-color: var(--violet); border-radius: 50%; padding: 2.6mm; margin: 0; } +SvMedalLocked { color: var(--text-muted); background-color: var(--locked); border-radius: 50%; padding: 2.6mm; margin: 0; } +SvMedalName { background-color: transparent; font-size: 2.4mm; color: var(--text); padding: 0.8mm 0 0 0; margin: 0; } +SvMedalNameLocked { background-color: transparent; font-size: 2.4mm; color: var(--text-muted); padding: 0.8mm 0 0 0; margin: 0; } +SvInk { background-color: var(--ink); border-radius: 3.4mm; padding: 3.4mm; margin: 1.4mm 0 1.4mm 0; } +SvInkEyebrow { background-color: transparent; font-family: "native:MainBold"; font-size: 2.4mm; color: var(--lime); padding: 0; margin: 0; } +SvInkTitle { background-color: transparent; font-family: "native:MainBold"; font-size: 5mm; color: var(--ink-text); padding: 0.6mm 0 0.6mm 0; margin: 0; } +SvInkText { background-color: transparent; font-size: 2.9mm; color: var(--ink-muted); padding: 0; margin: 0; } +SvInkChip { font-family: "native:MainBold"; font-size: 2.6mm; color: var(--ink); background-color: var(--lime); border-radius: 3mm; padding: 1mm 2.8mm 1mm 2.8mm; margin: 0 1.4mm 0 0; } +SvInkButton { font-family: "native:MainBold"; font-size: 3mm; color: var(--ink); background-color: var(--lime); border-radius: 4mm; padding: 1.8mm 3.6mm 1.8mm 3.6mm; margin: 2mm 0 0 0; text-align: center; } +SvInkButton.pressed { background-color: #ffffff; } +SvBeat { background-color: transparent; color: var(--lime); padding: 0; margin: 0; } +SvTile { background-color: var(--surface-alt); border-radius: 3mm; padding: 2mm 0.6mm 2mm 0.6mm; margin: 0.8mm; } +SvInkRow { background-color: transparent; padding: 0; margin: 1.6mm 0 0 0; } +SvLevelBox { background-color: transparent; padding: 0 0 0 3mm; margin: 0; } +SvTileValue { background-color: transparent; font-family: "native:MainBold"; font-size: 4.4mm; color: var(--text); padding: 0; margin: 0; } +SvTileLabel { background-color: transparent; font-size: 2.3mm; color: var(--text-muted); padding: 0; margin: 0; } +SvCallout { background-color: var(--violet-tint); border-radius: 3mm; padding: 2.6mm; margin: 1.4mm 0 1.4mm 0; } +SvCalloutText { font-size: 2.9mm; color: var(--violet); padding: 0; margin: 0; } + +SvWelcome { background-color: var(--ink); padding: 10mm 5mm 4mm 5mm; margin: 0; } +SvWelcomeActions { background-color: transparent; padding: 2mm 5mm 4mm 5mm; margin: 0; } +SvWelcomeIcon { background-color: transparent; color: var(--lime); padding: 0 2mm 0 0; margin: 0; } +SvWelcomeTitle { background-color: transparent; font-family: "native:MainBold"; font-size: 11mm; color: var(--ink-text); padding: 4mm 0 0 0; margin: 0; } +SvWelcomeTag { background-color: transparent; font-size: 4.2mm; color: var(--ink-muted); padding: 0 0 6mm 0; margin: 0; } +SvWelcomeFeature { background-color: var(--ink-raised); border-radius: 3mm; padding: 2.6mm; margin: 1.2mm 0 1.2mm 0; } +SvWelcomeFeatureIcon { background-color: transparent; color: var(--lime); padding: 0 2.6mm 0 0; margin: 0; } +SvWelcomeFeatureText { background-color: transparent; font-size: 3.1mm; color: var(--ink-text); padding: 0; margin: 0; } + +@media (prefers-color-scheme: dark) { + SvDock { background-color: var(--surface-dark); border-top: 1px solid var(--surface-press-dark); } + SvWelcome { background-color: var(--ink-dark); } + SvWelcomeFeature { background-color: var(--ink-raised-dark); } + SvClass { background-color: var(--surface-alt-dark); border: 1px solid var(--surface-press-dark); } + SvClassStripe { background-color: var(--brand-dark); } + SvClassScarce { color: var(--brand-dark); } + SvClassFriends { color: var(--violet-dark); } + SvPillBook { color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvPillQueue { color: var(--violet-dark); background-color: var(--violet-tint-dark); } + SvInk { background-color: var(--ink-dark); } + SvLevelTrack { color: var(--ink-raised-dark); } + SvMedal { background-color: var(--violet-dark); } + SvMedalLocked { background-color: var(--locked-dark); } + SvTile { background-color: var(--surface-alt-dark); } + SvCallout { background-color: var(--violet-tint-dark); } + SvCalloutText { color: var(--violet-dark); } + SvForm { background-color: var(--surface-dark); } + SvPage { background-color: var(--surface-dark); } + SvHero { background-color: var(--brand-dark); } + SvHeroTitle { color: var(--brand-contrast-dark); } + SvHeroText { color: var(--brand-contrast-dark); } + SvHeroIcon { color: var(--brand-contrast-dark); } + SvCard { background-color: var(--surface-dark); } + SvFloat { background-color: var(--surface-dark); } + SvPanel { background-color: var(--surface-alt-dark); } + SvDivider { background-color: var(--line-dark); } + SvTitle { color: var(--text-dark); } + SvHeading { color: var(--text-dark); } + SvText { color: var(--text-dark); } + SvMuted { color: var(--text-muted-dark); } + SvPill { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvPill.selected { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvPill.pressed { color: var(--ink-dark); background-color: var(--lime); } + SvChartTitle { color: var(--text-muted-dark); } + SvRankBar { color: var(--surface-alt-dark); } + SvFrontScan { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvFrontScan.pressed { background-color: var(--brand-pressed-dark); } + SvFrontFob { color: var(--text-dark); background-color: var(--surface-alt-dark); border: 1px solid var(--line-dark); } + SvFrontFob.pressed { background-color: var(--surface-press-dark); } + SvLabel { color: var(--text-muted-dark); } + SvPrice { color: var(--text-dark); } + SvError { color: var(--danger-dark); } + SvNotice { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvPrimary { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvPrimary.pressed { background-color: var(--brand-pressed-dark); } + SvPrimary.disabled { color: var(--text-muted-dark); background-color: var(--surface-press-dark); } + SvSecondary { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSecondary.pressed { background-color: var(--surface-press-dark); } + SvDanger { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvDanger.pressed { background-color: var(--surface-press-dark); } + SvLink { color: var(--brand-dark); } + SvLink.pressed { color: var(--brand-pressed-dark); background-color: var(--brand-tint-dark); } + SvMapButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + SvMapButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + SvBarButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + SvBarButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + SvField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSearchField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvRowAction { background-color: var(--brand-tint-dark); } + SvRowActionQuiet { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvFieldHint { color: var(--text-muted-dark); } + SvRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvRow.pressed { background-color: var(--surface-alt-dark); } + SvRowIcon { color: var(--text-muted-dark); } + SvRowTitle { color: var(--text-dark); } + SvRowDetail { color: var(--text-muted-dark); } + SvRowValue { color: var(--text-dark); } + SvBadge { background: cn1-pill-border; color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvBadgeGood { background: cn1-pill-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + SvBadgeWarn { background: cn1-pill-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvBadgeBad { background: cn1-pill-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvStat { background-color: var(--surface-alt-dark); } + SvStatValue { color: var(--text-dark); } + SvStatLabel { color: var(--text-muted-dark); } + StatusBar { background-color: var(--surface-dark); } + Toolbar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + Title { color: var(--text-dark); } + TitleCommand { color: var(--text-dark); } + BackCommand { color: var(--text-dark); } + SvChip { background: cn1-pill-border; color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSheet { background-color: var(--surface-dark); } + SvGrip { color: var(--line-strong-dark); } + SvPulse { color: var(--brand-dark); } + SvShortcut { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvShortcut.pressed { background-color: var(--surface-press-dark); } + SvAvatar { background: cn1-round-border; color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvAction { color: var(--text-dark); } + SvAction.pressed { color: var(--brand-dark); background-color: var(--surface-alt-dark); } + SvActionDanger { color: var(--danger-dark); } + SvActionDanger.pressed { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvMenu { background-color: var(--surface-dark); } + SvMenuTop { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvMenuName { color: var(--text-dark); } + SvMenuDetail { color: var(--text-muted-dark); } + SvModeBadge { background: cn1-pill-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvMenuLabel { color: var(--text-muted-dark); } + SvMenuItem { color: var(--text-dark); } + SvMenuItem.pressed { background-color: var(--surface-alt-dark); } + SvMenuDock { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + SvMenuItemPicked { background-color: var(--brand-tint-dark); } + SvMenuBottom { border-top: 1px solid var(--line-dark); } + SvHomeBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvSection { color: var(--text-dark); } + SvSegments { background-color: var(--surface-alt-dark); } + SvSegment { color: var(--text-muted-dark); } + SvSegment.selected { color: var(--text-muted-dark); background-color: transparent; } + SvSegment.pressed { color: var(--text-dark); background-color: var(--surface-press-dark); } + SvList { background-color: var(--surface-dark); } + SvFactLabel { color: var(--text-muted-dark); } + SvFactValue { color: var(--text-dark); } + SvFactTotal { color: var(--text-dark); } + SvCallout { background-color: var(--warning-tint-dark); } + SvCalloutIcon { color: var(--warning-dark); } + SvCalloutTitle { color: var(--text-dark); } + SvCalloutText { color: var(--text-dark); } + SvBannerIcon { background: cn1-round-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvBannerIconGood { background: cn1-round-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + SvBannerIconWarn { background: cn1-round-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvBannerIconBad { background: cn1-round-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvBannerTitle { color: var(--text-dark); } + SvBannerText { color: var(--text-muted-dark); } + SvAmount { color: var(--text-dark); } + SvChart { color: var(--brand-dark); } + SvChartPeak { color: var(--brand-pressed-dark); } + SvChartGrid { color: var(--line-dark); } + SvChartAxis { color: var(--text-muted-dark); } + SvChartValue { color: var(--text-dark); } + SvMeter { color: var(--brand-dark); } + SvAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvAlert.pressed { background-color: var(--surface-press-dark); } + SvOption { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + SvOption.pressed { background-color: var(--surface-alt-dark); } + SvOptionPicked { background-color: var(--brand-tint-dark); border: 2px solid var(--brand-dark); } + SvOptionIcon { color: var(--text-dark); } + SvCardFace { background-color: var(--brand-dark); } + SvCardBrand { color: var(--brand-contrast-dark); } + SvCardNumber { color: var(--brand-contrast-dark); } + SvCardSmall { color: var(--brand-contrast-dark); } + SvThumb { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvFooter { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + SvSide { background-color: var(--surface-alt-dark); border-right: 1px solid var(--line-dark); } + SvSideName { color: var(--text-dark); } + SvSideDetail { color: var(--text-muted-dark); } + SvSideLabel { color: var(--text-muted-dark); } + SvSideItem { color: var(--text-dark); } + SvSideItem.pressed { background-color: var(--line-dark); } + SvSidePicked { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvMaster { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + SvPaneBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvPaneTitle { color: var(--text-dark); } + SvRowPicked { background-color: var(--surface-alt-dark); } + SvSecondary.disabled, SvDanger.disabled, SvLink.disabled { color: var(--line-strong-dark); } + SvField.selected { background-color: var(--brand-tint-dark); } + SvSearchField.selected { background-color: var(--brand-tint-dark); } + SvField.disabled { color: var(--text-muted-dark); } + Switch { color: #ffffff; background-color: var(--line-strong-dark); } + Switch.selected { color: #ffffff; background-color: var(--brand-dark); } + Switch.disabled { color: var(--text-muted-dark); background-color: var(--line-dark); } + SvSegment.disabled { color: var(--line-strong-dark); } + SvSkeleton { color: var(--surface-alt-dark); } + PullToRefresh { color: var(--text-muted-dark); } + ToastBar { background-color: var(--text-dark); } + ToastBarMessage { color: var(--surface-dark); } + SvDeskForm { background-color: var(--window-bg-color-dark); } + SvDeskPage { background-color: var(--window-bg-color-dark); } + SvDeskList { background-color: var(--view-bg-color-dark); } + SvDeskMaster { background-color: var(--view-bg-color-dark); border-right: 1px solid #3a3a3a; } + SvDeskHomeBar { background-color: var(--headerbar-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskPaneBar { background-color: var(--window-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskPaneTitle { color: var(--text-color-dark); } + SvDeskSide { background-color: var(--headerbar-bg-color-dark); border-right: 1px solid #3a3a3a; } + SvDeskSideName { color: var(--text-color-dark); } + SvDeskSideDetail { color: var(--text-secondary-color-dark); } + SvDeskSideLabel { color: var(--text-secondary-color-dark); } + SvDeskSideItem { color: var(--text-color-dark); } + SvDeskSideItem.pressed { background-color: var(--separator-color-dark); } + SvDeskSidePicked { color: var(--accent-fg-color-dark); background-color: var(--accent-color-dark); } + SvDeskDanger { color: var(--danger-dark); } + SvDeskRow { border-bottom: 1px solid #3a3a3a; } + SvDeskRow.pressed { background-color: var(--separator-color-dark); } + SvDeskRowPicked { background-color: var(--separator-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvDeskAlert.pressed { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvDeskRowIcon { color: var(--text-secondary-color-dark); } + SvDeskRowTitle { color: var(--text-color-dark); } + SvDeskRowDetail { color: var(--text-secondary-color-dark); } + SvDeskRowValue { color: var(--text-color-dark); } + SvDeskSection { color: var(--text-color-dark); } + SvDeskTitle { color: var(--text-color-dark); } + SvDeskHeading { color: var(--text-color-dark); } + SvDeskText { color: var(--text-color-dark); } + SvDeskMuted { color: var(--text-secondary-color-dark); } + SvDeskLabel { color: var(--text-secondary-color-dark); } + SvDeskError { color: var(--danger-dark); } + SvDeskNotice { color: var(--text-color-dark); } + SvDeskFactLabel { color: var(--text-secondary-color-dark); } + SvDeskFactValue { color: var(--text-color-dark); } + SvDeskFactTotal { color: var(--text-color-dark); } + SvDeskAmount { color: var(--text-color-dark); } + SvDeskBannerIcon { color: var(--text-secondary-color-dark); } + SvDeskBannerTitle { color: var(--text-color-dark); } + SvDeskBannerText { color: var(--text-secondary-color-dark); } + SvDeskStatValue { color: var(--text-color-dark); } + SvDeskStatLabel { color: var(--text-secondary-color-dark); } + SvDeskChartValue { color: var(--text-color-dark); } + SvDeskChartAxis { color: var(--text-secondary-color-dark); } + SvHomeHeader { background-color: var(--surface-dark); } + SvGreeting { color: var(--text-dark); } + SvMutedSmall { color: var(--text-muted-dark); } + SvMetaLine { color: var(--text-dark); } + SvTabBar { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + SvTab { color: var(--text-muted-dark); } + SvTab.pressed { color: var(--text-dark); } + SvTabSelected { color: var(--brand-dark); } + SvSheetTitle { color: var(--text-dark); } + SvTotal { color: var(--text-dark); } + SvQueuePane { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + SvDetailPane { background-color: var(--surface-alt-dark); } + SvQueueRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvQueueRow.pressed { background-color: var(--surface-alt-dark); } + SvQueueRowPicked { background-color: var(--brand-tint-dark); border-bottom: 1px solid var(--line-dark); } + + /* Class cards, medals, kudos and tiles. */ + SvWorkoutWorn { color: var(--violet-dark); } + SvPoints { color: var(--ink-dark); } + SvKudos { color: var(--brand-dark); background-color: var(--surface-alt-dark); } + SvKudos.pressed { background-color: var(--surface-press-dark); } + SvKudos.disabled { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvKudosGiven { background-color: var(--brand-dark); } + SvKudosGiven.disabled { background-color: var(--brand-dark); } + SvKudosMine { color: var(--text-muted-dark); } + SvKudosMine.disabled { color: var(--text-muted-dark); } + SvClassClock { color: var(--text-dark); } + SvClassLength { color: var(--text-muted-dark); } + SvClassName { color: var(--text-dark); } + SvClassWho { color: var(--text-muted-dark); } + SvClassLeft { color: var(--text-muted-dark); } + SvClassFull { color: var(--danger-dark); } + SvPillBook.pressed { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvPillBooked { color: var(--success-dark); background-color: var(--success-tint-dark); } + SvMedalLocked { color: var(--text-muted-dark); } + SvMedalName { color: var(--text-dark); } + SvMedalNameLocked { color: var(--text-muted-dark); } + SvInkChip { color: var(--ink-dark); } + SvInkButton { color: var(--ink-dark); } + SvTileValue { color: var(--text-dark); } + SvTileLabel { color: var(--text-muted-dark); } + SvRowAmount { color: var(--text-dark); } + + /* The owner's insights, members and billing. */ + SvHeatmap { color: var(--brand-dark); } + SvDonut { color: var(--text-dark); } + SvLegendActive { color: var(--brand-dark); } + SvLegendJoined { color: var(--success-dark); } + SvLegendLeft { color: var(--danger-dark); } + SvLegendRow { border-bottom: 1px solid var(--line-dark); } + SvSearchBar { background-color: var(--surface-dark); } +} +SvRowAmount { background-color: transparent; font-family: "native:MainBold"; font-size: 3.6mm; color: var(--text); padding: 0 0 0 2mm; margin: 0; } +SvClassTile { background-color: transparent; padding: 2mm; margin: 0 3mm 0 0; } + +/* The owner's insights: the busy-hours heatmap (its colour is the cells', + its font the labels'), the plan donut (the total in the middle) and the + membership chart's legend, whose colours the chart draws its series in. */ +SvHeatmap { color: var(--brand); background-color: transparent; font-family: "native:MainRegular"; font-size: 2mm; padding: 1.6mm 0 1mm 0; margin: 0; } +SvDonut { color: var(--text); background-color: transparent; font-family: "native:MainBold"; font-size: 5mm; padding: 2mm; margin: 0; } +SvLegendActive { color: var(--brand); background-color: transparent; font-family: "native:MainBold"; font-size: 2.3mm; padding: 1mm 3mm 0 0; margin: 0; } +SvLegendJoined { cn1-derive: SvLegendActive; color: var(--success); } +SvLegendLeft { cn1-derive: SvLegendActive; color: var(--danger); } +SvLegendRow { background-color: transparent; border-bottom: 1px solid var(--line); padding: 2mm 0 2mm 0; margin: 0; } +/* The search field and chips over the member list and the billing summary. */ +SvSearchBar { background-color: var(--surface); padding: 2mm 3mm 0 3mm; margin: 0; } diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/AppConfig.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/AppConfig.java new file mode 100644 index 00000000000..1c7b17658cd --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/AppConfig.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; + +/// The few things about the app that are settings and not code. +public final class AppConfig { + /// Where the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. Change this to your deployment's address + /// before you ship; until then the settings screen can point a build at + /// another server, which is how a phone reaches a laptop on the same network. + public static final String DEFAULT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by. The same constant is in the + /// server's `SecurityConfig`. + public static final String CLIENT_ID = "pulse-app"; + /// The address the server hands the authorization code back to. The app + /// reads the code out of the redirect itself and never opens this address, + /// so it only has to be one the server has on its list. + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + // tag::pulse-push-key[] + /// The app's push key, from the Push section of the Codename One console. + /// Empty, the app registers for no notifications: everything still works, + /// and a phone in a pocket simply hears nothing until it is opened. + public static final String PUSH_APP_KEY = ""; + // end::pulse-push-key[] + /// Where the map opens when the device will not say where it is. + public static final double DEFAULT_LAT = 37.8080; + public static final double DEFAULT_LNG = -122.4120; + + private static final String SERVER_KEY = "pulse.server"; + private static final String MODE_KEY = "pulse.mode"; + private static final String MODE_OWNER_KEY = "pulse.mode.owner"; + + private AppConfig() { + } + + /// The server's address, with no trailing slash. A `pulse.server.url` + /// system property wins, which is what the tests use; then the address saved + /// from the settings screen; then, in a browser, the address the page came + /// from, since the server that hosts the app is the server it talks to; + /// then [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("pulse.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + // In a browser it is always the page's own server: see + // serverSwitchable(). An address saved before that is ignored. + url = origin != null ? origin : Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Whether the app may be pointed at another server. Not in a browser: + /// there the authorization code is sent back to the page's own server + /// ([#redirectUri]), and another server has not registered that address + /// as one of its own, so every sign-in after a switch would be refused. + /// The hosted app is the server's own, and talks to that server. + public static boolean serverSwitchable() { + return pageOrigin() == null; + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. + /// + /// The installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back. + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + public static void setServerUrl(String url) { + Preferences.set(SERVER_KEY, clean(url)); + } + + /// The mode the account `owner` (see + /// [com.codenameone.examples.pulse.net.Session#owner(String)]) chose last + /// -- `member`, `desk`, `trainer` or `owner` -- or + /// empty when they have chosen none, or the choice on record is someone + /// else's. A choice recorded with no name is anyone's: that is how a test + /// puts the app in a mode before it signs in. + public static String mode(String owner) { + String chosenBy = Preferences.get(MODE_OWNER_KEY, ""); + if (chosenBy.length() > 0 && !chosenBy.equals(owner)) { + return ""; + } + return Preferences.get(MODE_KEY, ""); + } + + /// Records `mode` as chosen by whoever signs in next. + public static void setMode(String mode) { + setMode(mode, ""); + } + + public static void setMode(String mode, String username) { + Preferences.set(MODE_KEY, mode == null ? "" : mode); + Preferences.set(MODE_OWNER_KEY, username == null ? "" : username); + } + + /// Whether `url` names the server `current` does, as [#setServerUrl] + /// would store it. + public static boolean sameServer(String url, String current) { + return clean(url).equals(current); + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return DEFAULT_SERVER; + } + // Compared without folding the case: a scheme is ASCII, and + // toLowerCase() follows the device's language. + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "http://" + text; + } + return text; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Prefs.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Prefs.java new file mode 100644 index 00000000000..32a549b03fd --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Prefs.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.io.Preferences; + +/// What the user has chosen in Settings. +/// +/// Every screen reads and writes a preference through this class and through +/// nothing else, so where the values live is decided in one place. Today that +/// is the device: `Preferences`, which survives a restart and is gone with the +/// app. A server that keeps the same values for the account plugs in at +/// [#onChange]: it is told the name of each value as it changes, and puts the +/// account's values in with the setters when it has fetched them. No screen +/// has to change for that. +public final class Prefs { + /// Told when a value changes, with the value's name. + public interface Listener { + void changed(String name); + } + + public static final String LANGUAGE = "language"; + public static final String THEME = "theme"; + public static final String UNITS = "units"; + public static final String NOTIFY_CLASSES = "notifyClasses"; + public static final String NOTIFY_RECEIPTS = "notifyReceipts"; + public static final String NOTIFY_PROMOTIONS = "notifyPromotions"; + public static final String PAYMENT_METHOD = "defaultPaymentMethodId"; + public static final String QUIET_FROM = "quietFrom"; + public static final String QUIET_TO = "quietTo"; + /// What the listener is told when the values change hands; see [#ownedBy]. + public static final String OWNER = "owner"; + /// Whose the stored values are: see [#ownedBy]. + private static final String RECENTS_OWNER = "recentsOwner"; + + public static final String THEME_SYSTEM = "system"; + public static final String THEME_LIGHT = "light"; + public static final String THEME_DARK = "dark"; + public static final String UNITS_KM = "km"; + public static final String UNITS_MILES = "mi"; + /// The one way to pay that needs nothing set up. + public static final String PAYMENT_CASH = "cash"; + + private static final String PREFIX = "pulse.pref."; + /// The plain values that are a copy of the account's; see [#ownedBy]. + private static final String[] ACCOUNT_OWNED = {NOTIFY_CLASSES, NOTIFY_RECEIPTS, + NOTIFY_PROMOTIONS, PAYMENT_METHOD, QUIET_FROM, QUIET_TO}; + + private static Listener listener; + + private Prefs() { + } + + /// Names the one listener told of every change. A value put in by the + /// listener itself is reported to it like any other, so a listener that + /// stores what it is told must not answer by setting the value again. + public static void onChange(Listener told) { + listener = told; + } + + // -------------------------------------------------------------- appearance + + /// The language chosen, as its two-letter code; empty while the user has + /// chosen none and the device's language decides. + public static String language() { + return text(LANGUAGE, ""); + } + + public static void setLanguage(String code) { + put(LANGUAGE, code); + } + + /// [#THEME_SYSTEM], [#THEME_LIGHT] or [#THEME_DARK]. + public static String theme() { + return text(THEME, THEME_SYSTEM); + } + + public static void setTheme(String theme) { + put(THEME, theme); + } + + /// [#UNITS_KM] or [#UNITS_MILES]. + public static String units() { + return text(UNITS, UNITS_KM); + } + + public static void setUnits(String units) { + put(UNITS, units); + } + + // ----------------------------------------------------------- notifications + + public static boolean notifyClasses() { + return flag(NOTIFY_CLASSES, true); + } + + public static void setNotifyClasses(boolean on) { + put(NOTIFY_CLASSES, on); + } + + public static boolean notifyReceipts() { + return flag(NOTIFY_RECEIPTS, true); + } + + public static void setNotifyReceipts(boolean on) { + put(NOTIFY_RECEIPTS, on); + } + + public static boolean notifyPromotions() { + return flag(NOTIFY_PROMOTIONS, false); + } + + public static void setNotifyPromotions(boolean on) { + put(NOTIFY_PROMOTIONS, on); + } + + // --------------------------------------------------------------- payment + + /// The id of the way the user pays unless they say otherwise for an invoice. + public static String paymentMethod() { + return text(PAYMENT_METHOD, PAYMENT_CASH); + } + + public static void setPaymentMethod(String id) { + put(PAYMENT_METHOD, id); + } + + /// The hour of the day (0-23) from which no class reminder is shown. + public static int quietFrom() { + return Preferences.get(PREFIX + QUIET_FROM, 22); + } + + /// The hour of the day (0-23) at which class reminders may be shown again. + public static int quietTo() { + return Preferences.get(PREFIX + QUIET_TO, 7); + } + + public static void setQuiet(int from, int to) { + if (from != quietFrom() || to != quietTo()) { + Preferences.set(PREFIX + QUIET_FROM, from); + Preferences.set(PREFIX + QUIET_TO, to); + changed(QUIET_FROM); + } + } + + // ------------------------------------------------------------- the owner + + /// The account the stored values now belong to. A different account than + /// the last one forgets the values that are a copy of the previous + /// account's, so they are not sent to the server as the new one's. + /// + /// The listener is told with [#OWNER]. It sends what it is told of to the + /// server, and values dropped here were not changed by anyone: reported, + /// they would be saved over the new account's. + /// + /// @param account the account, with its server, as + /// [com.codenameone.examples.pulse.net.Session#owner(String)] names + /// it, or empty for none + public static void ownedBy(String account) { + String owner = account == null ? "" : account; + if (owner.equals(text(RECENTS_OWNER, ""))) { + return; + } + for (String name : ACCOUNT_OWNED) { + Preferences.delete(PREFIX + name); + } + Preferences.set(PREFIX + RECENTS_OWNER, owner); + changed(OWNER); + } + + // ------------------------------------------------------------- the store + + private static String text(String name, String fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static boolean flag(String name, boolean fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static void put(String name, String value) { + String wanted = value == null ? "" : value.trim(); + if (!wanted.equals(text(name, null))) { + Preferences.set(PREFIX + name, wanted); + changed(name); + } + } + + private static void put(String name, boolean value) { + // Compared with the opposite as the fallback, so a value never stored + // is stored the first time whatever it is. + if (flag(name, !value) != value) { + Preferences.set(PREFIX + name, value); + changed(name); + } + } + + private static void changed(String name) { + if (listener != null) { + listener.changed(name); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Pulse.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Pulse.java new file mode 100644 index 00000000000..24549b1f356 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Pulse.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.system.Lifecycle; +import com.codenameone.examples.pulse.net.Account; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.notify.Notifications; +import com.codenameone.examples.pulse.member.Reminders; +import com.codenameone.examples.pulse.pay.CardForm; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Look; +import com.codenameone.examples.pulse.ui.Nav; + +/// The app. It opens on whatever the stored session allows: the mode the user +/// was last in when they are still signed in, the welcome screen when not. +/// +/// Where things are: +/// +/// - `net` -- the session, and the typed clients generated from the contracts in +/// the `shared` module. Nothing else in the app builds a URL. +/// - `live` -- the WebSocket the server pushes changes down. +/// - `notify` -- push notifications, for a phone that is not looking. +/// - `ui` -- sign-in, settings and the pieces every screen is built from. +/// - `member` -- the timetable, bookings, the pass, the household and the plan. +/// - `desk` -- the front desk kiosk: scanning passes, NFC fobs and walk-ins. +/// - `trainer`, `owner` -- the coaching and studio-owner modes. +/// - `pay` -- saved cards. +/// - `src/main/css/theme.css` -- every colour, font and margin. +/// - `src/main/l10n` -- every word, in each language the app speaks. +/// - `Prefs` -- what the user chose in Settings. +/// - `Telemetry` -- what is reported about how the app is used, once the user +/// has agreed to it. +public class Pulse extends Lifecycle { + @Override + public void runApp() { + // Before the first screen is built: a screen keeps the look and the + // words it was built with. + Look.apply(); + Lang.apply(); + Telemetry.start(); + Session.start(AppConfig.serverUrl()); + Account.start(); + Nav.launch(); + } + + /// Coming back to the app is how a card added on the payment provider's + /// page is noticed: the browser it opened in tells the app nothing. + /// + /// It is also when the app asks for its push token, which the platform + /// answers at once when nothing has changed, and when the class reminders + /// are brought in line with the bookings, which may have changed elsewhere. + @Override + public void start() { + super.start(); + CardForm.resumed(); + Notifications.start(); + Reminders.resumed(); + } + + /// The app is put away, which is the last sure moment to send the usage + /// reports gathered since the last batch. + @Override + public void stop() { + super.stop(); + Telemetry.flush(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Telemetry.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Telemetry.java new file mode 100644 index 00000000000..134038728d8 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/Telemetry.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.AnalyticsProvider; +import com.codename1.analytics.CodenameOneAnalyticsProvider; +import com.codename1.analytics.LoggingAnalyticsProvider; +import com.codename1.ui.CN; +import com.codenameone.examples.pulse.ui.Lang; + +/// What the app reports about how it is used, and whether it may. +/// +/// This is the only class that talks to `com.codename1.analytics`. A screen +/// says what happened by calling one of the methods here, so the whole +/// vocabulary -- every event name and every parameter -- is in this file, and +/// it is here that the one rule is kept: nothing that says who someone is or +/// where they are ever goes into an event. No name, e-mail address, phone +/// number, address, position, card number or booking id; an amount of money goes +/// as the bracket it falls in. What is known about the user is two things: the +/// mode they are in and the language they read. +/// +/// Nothing is reported until the user has said yes. The library starts with +/// consent withheld and drops every call made without it, so a method here is +/// always safe to call. The answer is asked for once, after the first sign-in +/// on a device ([#asked()], `ui.ConsentForm`), can be changed in Settings at +/// any time ([#allow(boolean)]), and is kept by the library on the device, in +/// `Preferences`, with the made-up id that tells one installation from the +/// next. Deleting the account forgets both ([#forget()]). +/// +/// On a device the reports go to Codename One's own service, which shows them +/// in the developer console. That service knows which app is reporting from +/// the identity a build stamps into it, so the simulator, which has none, only +/// writes what it would have sent to the log. +public final class Telemetry { + private static final String ACCOUNT = "account"; + private static final String CLASSES = "classes"; + private static final String PAYMENT = "payment"; + private static final String DESK = "desk"; + private static final String SETTINGS = "settings"; + + /// The screen reported last, which the next one names as where it came from. + private static String screen; + /// The screen that is showing, reported or not. + private static String showing; + private static String role; + /// The role and the language as they were last reported. + private static String described; + private static boolean started; + + private Telemetry() { + } + + /// Says where the reports go. Called once, when the app starts. + public static void start() { + if (started) { + return; + } + started = true; + if (CN.isSimulator()) { + Analytics.addProvider(new LoggingAnalyticsProvider()); + } else { + Analytics.addProvider(new CodenameOneAnalyticsProvider()); + } + } + + /// Sends the reports to `providers` and nowhere else. For a test, which + /// wants to see what was reported and wants none of it to leave. + public static void use(AnalyticsProvider... providers) { + started = true; + Analytics.clearProviders(); + for (AnalyticsProvider provider : providers) { + Analytics.addProvider(provider); + } + screen = null; + described = null; + } + + // --------------------------------------------------------------- consent + + /// Whether the user has answered, either way. + public static boolean asked() { + return Analytics.getConsent() != null; + } + + /// Whether the user said yes. + public static boolean allowed() { + AnalyticsConsent consent = Analytics.getConsent(); + return consent != null && consent.isAnalytics(); + } + + /// Records the user's answer. A yes covers how the app is used and the + /// errors it runs into; nothing here follows a person or serves an + /// advertisement, and those are never granted. + public static void allow(boolean yes) { + boolean was = allowed(); + Analytics.setConsent(AnalyticsConsent.builder() + .analytics(yes) + .crashReporting(yes) + .personalization(false) + .adStorage(false) + .build()); + if (yes && !was) { + // What was dropped while the answer was no: who is using the app, + // in the two words kept about them, and the screen they are on. + screen = null; + described = null; + describe(); + if (showing != null) { + screen(showing); + } + } + } + + /// Takes the answer back, so that it is asked for again. + public static void unask() { + Analytics.setConsent(null); + } + + /// The account is gone: the installation gets a new id, which cuts what is + /// reported from now on from everything reported before, and whoever signs + /// in next is asked for themselves. + public static void forget() { + // What is waiting was gathered under the id that is about to go, and + // is sent under it. + Analytics.flush(); + Analytics.resetClientId(); + Analytics.setConsent(null); + role = null; + screen = null; + described = null; + } + + /// Sends what is waiting. Reports are gathered and sent a batch at a + /// time; this is for when the app is put away. + public static void flush() { + Analytics.flush(); + } + + // ------------------------------------------------------- screens and who + + /// A screen is showing. `name` is the one the screen was built with, which + /// is not translated and does not change with its title. + public static void screen(String name) { + if (name == null || name.length() == 0) { + return; + } + showing = name; + if (name.equals(screen) || !allowed()) { + return; + } + Analytics.screen(name, screen); + screen = name; + } + + /// The user is in `mode`: member, desk, trainer or owner. + public static void role(String mode) { + role = mode; + describe(); + } + + /// Reports the role and the language, when either is news: the home + /// screen is built again for every change of look, and says its mode each + /// time. + private static void describe() { + if (role == null || !allowed()) { + return; + } + String language = Lang.current(); + String now = role + "/" + language; + if (!now.equals(described)) { + described = now; + Analytics.setUserProperty("role", role); + Analytics.setUserProperty("language", language); + } + } + + // --------------------------------------------------------------- account + + public static void signedIn() { + send(ACCOUNT, "login", "method", "password"); + } + + /// @param reason `user` when they chose to, `expired` when the session + /// ended under them, `deleted` when the account was closed + public static void signedOut(String reason) { + send(ACCOUNT, "logout", "reason", reason); + role = null; + } + + public static void registered() { + send(ACCOUNT, "sign_up", "method", "password"); + } + + public static void phoneVerified() { + send(ACCOUNT, "phone_verified"); + } + + // ------------------------------------------------------------------ classes + + /// @param waitlisted whether the class was full and the booking joined its queue + /// @param forChild whether it was booked for someone in the household + public static void booked(boolean waitlisted, boolean forChild) { + send(CLASSES, waitlisted ? "class_waitlisted" : "class_booked", "for", + forChild ? "child" : "self"); + } + + public static void bookingCancelled() { + send(CLASSES, "class_cancelled"); + } + + public static void addedToCalendar() { + send(CLASSES, "class_added_to_calendar"); + } + + // --------------------------------------------------------------- payment + + /// @param kind the plan's kind, as the server names it + public static void joined(String kind) { + send(PAYMENT, "membership_joined", "kind", kind == null ? "" : kind); + } + + public static void invoicePaid() { + send(PAYMENT, "invoice_paid"); + } + + public static void cardAdded() { + send(PAYMENT, "card_added"); + } + + public static void cardRemoved() { + send(PAYMENT, "card_removed"); + } + + // ------------------------------------------------------------------ desk + + /// @param how `qr`, `nfc` or `lookup` + public static void checkIn(String how, boolean admitted) { + send(DESK, admitted ? "check_in_admitted" : "check_in_refused", "how", how); + } + + public static void walkIn(String kind) { + send(DESK, "walk_in", "kind", kind == null ? "" : kind); + } + + // -------------------------------------------------------------- settings + + public static void languageChanged(String code) { + send(SETTINGS, "language_changed", "language", code); + describe(); + } + + public static void themeChanged(String theme) { + send(SETTINGS, "theme_changed", "theme", theme); + } + + // ---------------------------------------------------------------- errors + + /// Something failed that the app caught and carried on from. Only what + /// kind of failure it was is reported: a message can quote what the user + /// typed, or the file it was reading. + public static void error(Throwable failed) { + if (failed != null) { + Analytics.crash(failed, failed.getClass().getName(), false); + } + } + + // ------------------------------------------------------------- the store + + /// The bracket an amount of money falls in, in whole units of the + /// currency. The amount itself, beside a time, can be matched to a payment. + static String fare(long cents) { + long units = cents / 100; + return units < 5 ? "under_5" : units < 10 ? "5_to_10" : units < 20 ? "10_to_20" + : units < 35 ? "20_to_35" : units < 50 ? "35_to_50" : "50_or_more"; + } + + /// Reports `name` with `pairs`: a parameter's name, then its value. + private static void send(String category, String name, Object... pairs) { + AnalyticsEvent.Builder event = AnalyticsEvent.create(name).category(category); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + event.param((String) pairs[iter], pairs[iter + 1]); + } + Analytics.event(event.build()); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/DeskForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/DeskForm.java new file mode 100644 index 00000000000..c88587ab1a4 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/DeskForm.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.desk; + +import com.codename1.codescan.CodeScanner; +import com.codename1.codescan.ScanResult; +import com.codename1.nfc.Nfc; +import com.codename1.nfc.NfcReadOptions; +import com.codename1.nfc.Tag; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.CheckInRequestDto; +import com.codenameone.examples.pulse.api.CheckInResultDto; +import com.codenameone.examples.pulse.api.DeskDayDto; +import com.codenameone.examples.pulse.api.MemberSummaryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Menu; +import com.codenameone.examples.pulse.ui.Nav; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; + +/// The front desk, as a kiosk: a tablet at the door that members scan their +/// pass at or tap their key fob on. +/// +/// Three ways in, all answered by the server's `/api/desk/checkin`: +/// +/// - the QR code on the member's pass, read with `com.codename1.codescan`; +/// - the NFC fob, read with `com.codename1.nfc` -- its id, as hex, is what +/// the server knows the fob by; +/// - a name looked up by the staff member at the desk. +/// +/// Admitted or refused, the answer fills the screen with the reason the server +/// gave (past due, frozen, no class booked, a pass already used), so the +/// person at the door knows what to say. Below it is today: who came in, the +/// walk-ins and the next classes, kept fresh by the live channel. +public class DeskForm extends LiveChannel.Adapter implements Nav.Home { + private final Form form = Ui.home("Desk"); + private final Menu menu = new Menu(form, Nav.DESK); + private final Container verdict = Ui.card(); + private final Container today = new Container(BoxLayout.y()); + private String locationId = ""; + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + menu.item(FontImage.MATERIAL_QR_CODE_SCANNER, "Check in", "desk", () -> form.showBack()); + menu.item(FontImage.MATERIAL_PERSON_ADD, "Walk-in", "walkin", + () -> WalkInForm.show(form, locationId, this::loadToday)); + menu.item(FontImage.MATERIAL_SEARCH, "Find a member", "lookup", () -> lookup()); + + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, menu.flatButton()); + bar.add(BorderLayout.CENTER, Ui.label("Front desk", "SvTitle")); + + Container page = Ui.page(); + page.setName("desk"); + // Letting people in is the desk's whole job, so the two ways to do it + // are the biggest things on the screen; scanning, the common one, is + // the filled one. + Container buttons = new Container(new GridLayout(1, 2)); + buttons.add(deskAction(FontImage.MATERIAL_QR_CODE_SCANNER, "Scan pass", "scan", + "SvFrontScan", e -> scan())); + buttons.add(deskAction(FontImage.MATERIAL_NFC, "Tap fob", "nfc", "SvFrontFob", + e -> readFob())); + page.add(buttons); + + // For a kiosk with no camera, and for the simulator: the code typed + // in, as the scanner would have read it. + final TextField typed = Ui.field(page, "Or type the pass", "code", "Pass number"); + typed.addActionListener(e -> { + String text = typed.getText().trim(); + typed.setText(""); + if (text.length() > 0) { + checkIn(new CheckInRequestDto(text, null, null, locationId), "qr"); + } + }); + verdict.setName("verdict"); + verdict.add(Ui.text("Waiting for the next member.", "SvMuted")); + today.setName("today"); + page.add(verdict).add(today); + page.add(Ui.secondary("Walk-in", "walkin-button", + e -> WalkInForm.show(form, locationId, this::loadToday))); + + Container shell = new Container(new BorderLayout()); + shell.add(BorderLayout.NORTH, bar); + shell.add(BorderLayout.CENTER, page); + form.add(shell); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + loadToday(); + }); + Api.studio().locations(Net.to(locations -> { + if (locations != null && !locations.isEmpty()) { + locationId = locations.get(0).id(); + loadToday(); + } + }, Ui::fail)); + } + + @Override + public void checkedIn(String location, String name, boolean admitted) { + if (locationId.length() == 0 || locationId.equals(location)) { + loadToday(); + } + } + + private void scan() { + CodeScanner scanner = CodeScanner.getInstance(); + if (scanner == null) { + Ui.say("This device has no scanner. Type the pass instead"); + return; + } + scanner.scanQRCode(new ScanResult() { + @Override + public void scanCompleted(String contents, String formatName, byte[] rawBytes) { + CN.callSerially(() -> checkIn(new CheckInRequestDto(contents, null, null, + locationId), "qr")); + } + + @Override + public void scanCanceled() { + } + + @Override + public void scanError(int errorCode, String message) { + CN.callSerially(() -> Ui.say("The scan failed. Try again")); + } + }); + } + + private void readFob() { + final Nfc nfc = Nfc.getInstance(); + if (nfc == null || !nfc.isSupported() || !nfc.canRead()) { + Ui.say("This device cannot read NFC"); + return; + } + nfc.readTag(new NfcReadOptions().setAlertMessage(Lang.tr("Hold the fob near the top"))) + .ready(tag -> CN.callSerially(() -> fob(tag))) + .except(failed -> CN.callSerially(() -> Ui.say("The fob could not be read"))); + } + + private void fob(Tag tag) { + if (tag == null || tag.getId() == null) { + return; + } + checkIn(new CheckInRequestDto(null, hex(tag.getId()), null, locationId), "nfc"); + } + + /// Lower-case hex, as the server writes fob ids. + static String hex(byte[] bytes) { + String digits = "0123456789abcdef"; + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + out.append(digits.charAt((b >> 4) & 0xf)).append(digits.charAt(b & 0xf)); + } + return out.toString(); + } + + private void checkIn(CheckInRequestDto request, final String how) { + Api.desk().checkIn(request, Net.to(result -> { + Telemetry.checkIn(how, result.admitted()); + show(result); + loadToday(); + }, Ui::fail)); + } + + private void show(CheckInResultDto result) { + verdict.removeAll(); + verdict.add(Ui.banner(result.admitted() ? FontImage.MATERIAL_CHECK_CIRCLE + : FontImage.MATERIAL_CANCEL, result.admitted() ? Lang.tr("Welcome in") + : Lang.tr("Not admitted"), result.admitted() + ? (result.memberName() == null ? "" : result.memberName()) + : result.reason(), result.admitted() ? "SvBannerIconGood" : "SvBannerIconBad")); + if (result.memberName() != null) { + verdict.add(Ui.fact(Lang.tr("Member"), result.memberName())); + } + if (result.planName() != null) { + verdict.add(Ui.fact(Lang.tr("Plan"), result.planName())); + } + if (result.sessionName() != null && result.sessionName().length() > 0) { + verdict.add(Ui.fact(Lang.tr("Class"), result.sessionName() + " " + + Ui.when(result.sessionStartsAt()))); + } + if (result.admitted()) { + verdict.add(Ui.fact(Lang.tr("Visits this month"), + String.valueOf(result.visitsThisMonth()))); + } + Ui.refresh(form); + } + + private static Button deskAction(char icon, String text, String name, String uiid, + ActionListener onClick) { + Button button = Ui.action(icon, text, name, onClick); + button.setUIID(uiid); + Ui.icon(button, icon, 7f); + button.setGap(CN.convertToPixels(1.4f)); + return button; + } + + private void loadToday() { + Api.desk().today(locationId, Net.to(this::showToday, (status, message) -> { })); + } + + private void showToday(DeskDayDto day) { + if (day == null || !Nav.isHome(form)) { + return; + } + today.removeAll(); + Ui.section(today, "Today"); + StatRow stats = Ui.stats(3); + stats.stat(String.valueOf(day.checkIns()), Lang.tr("Check-ins"), "checkIns"); + stats.stat(String.valueOf(day.walkIns()), Lang.tr("Walk-ins"), "walkIns"); + stats.stat(Ui.moneyShort(day.walkInCents(), null), Lang.tr("Takings"), "takings"); + today.add(stats); + if (day.upcoming() != null && !day.upcoming().isEmpty()) { + Ui.section(today, "Coming up"); + } + for (int iter = 0; day.upcoming() != null && iter < day.upcoming().size() + && iter < 4; iter++) { + SessionDto session = day.upcoming().get(iter); + // The card already says how many places are left. + today.add(Classes.card(session, null, null)); + } + if (day.recent() != null && !day.recent().isEmpty()) { + Ui.section(today, "Just in"); + } + for (int iter = 0; day.recent() != null && iter < day.recent().size() + && iter < 6; iter++) { + CheckInResultDto recent = day.recent().get(iter); + String name = recent.memberName() == null ? "" : recent.memberName(); + Container row = Ui.row(FontImage.MATERIAL_PERSON, name, + recent.admitted() ? Lang.tr("Checked in") : recent.reason(), + Ui.badge(recent.admitted() ? Lang.tr("In") : Lang.tr("Turned away"), + recent.admitted() ? "SvBadgeGood" : "SvBadgeBad")); + BorderLayout layout = (BorderLayout) row.getLayout(); + Component icon = layout.getWest(); + if (icon != null) { + row.replace(icon, Ui.avatar(name), null); + } + today.add(row); + } + Ui.refresh(form); + } + + /// Staff at the desk look a member up by name and check them in by hand, + /// for a member whose phone is flat. + private void lookup() { + final Form search = Ui.form("Find a member", "Lookup"); + Ui.back(search, form); + Container page = Ui.page(); + final TextField query = Ui.field(page, "Name or e-mail", "query", ""); + final Container results = new Container(com.codename1.ui.layouts.BoxLayout.y()); + query.addActionListener(e -> Api.desk().find(query.getText().trim(), Net.to(found -> { + results.removeAll(); + for (int iter = 0; found != null && iter < found.size(); iter++) { + final MemberSummaryDto member = found.get(iter); + results.add(Ui.tap(Ui.row(FontImage.MATERIAL_PERSON, member.name(), + member.planName() == null ? member.membershipState() + : member.planName() + Ui.DOT + member.membershipState(), null), + "found-" + iter, ev -> { + form.showBack(); + checkIn(new CheckInRequestDto(null, null, member.id(), locationId), + "lookup"); + })); + } + Ui.refresh(search); + }, Ui::fail))); + page.add(results); + search.add(BorderLayout.CENTER, page); + search.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/WalkInForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/WalkInForm.java new file mode 100644 index 00000000000..f02ebbe53a6 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/desk/WalkInForm.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.desk; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.WalkInDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; + +/// Someone at the desk with no membership: a guest of a member, a day pass, or +/// a free trial. They are recorded as a lead, so the owner's campaigns can +/// follow up -- which is why the e-mail is asked for. +public final class WalkInForm { + private WalkInForm() { + } + + public static void show(final Form previous, final String locationId, final Runnable done) { + final Form form = Ui.form("Walk-in", "WalkIn"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", ""); + final TextField email = Ui.field(page, "E-mail", "email", "for the follow-up", + TextArea.EMAILADDR); + final TextField phone = Ui.field(page, "Mobile number", "phone", "optional", + TextArea.PHONENUMBER); + final String[] kind = {"DAY_PASS"}; + page.add(Ui.label("Visit", "SvLabel")); + page.add(Ui.choice("kind", new String[] {Lang.tr("Day pass"), Lang.tr("Trial"), + Lang.tr("Guest")}, new String[] {"DAY_PASS", "TRIAL", "GUEST"}, kind[0], + value -> kind[0] = value)); + final TextField amount = Ui.field(page, "Amount", "amount", "0.00", TextArea.DECIMAL); + final String[] paidBy = {"card"}; + page.add(Ui.label("Paid by", "SvLabel")); + page.add(Ui.choice("paidBy", new String[] {Lang.tr("Card"), Lang.tr("Cash"), + Lang.tr("Free")}, new String[] {"card", "cash", "none"}, paidBy[0], + value -> paidBy[0] = value)); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button save = Ui.primary("Let them in", "submit", null); + save.addActionListener(e -> { + save.setEnabled(false); + long cents = "none".equals(paidBy[0]) ? 0 : cents(amount.getText()); + Api.desk().walkIn(new WalkInDto(null, locationId, name.getText().trim(), + email.getText().trim(), phone.getText().trim(), kind[0], cents, paidBy[0], 0), + Net.to(saved -> { + Telemetry.walkIn(kind[0]); + Ui.say(Lang.tr("{0} is in", saved.name())); + previous.showBack(); + done.run(); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(error).add(save); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// "12.5" to 1250; anything unreadable is nothing. + static long cents(String typed) { + String text = typed == null ? "" : typed.trim(); + long whole = 0; + long fraction = 0; + int fractionDigits = -1; + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c == '.' || c == ',') { + if (fractionDigits >= 0) { + return 0; + } + fractionDigits = 0; + } else if (c >= '0' && c <= '9') { + if (fractionDigits < 0) { + whole = whole * 10 + (c - '0'); + } else if (fractionDigits < 2) { + fraction = fraction * 10 + (c - '0'); + fractionDigits++; + } + } else { + return 0; + } + } + if (fractionDigits == 1) { + fraction *= 10; + } + return whole * 100 + fraction; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/health/Workouts.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/health/Workouts.java new file mode 100644 index 00000000000..8ecd407ad68 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/health/Workouts.java @@ -0,0 +1,461 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.health; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.Health; +import com.codename1.health.HealthAccess; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthException; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.SampleQuery; +import com.codename1.health.WorkoutActivityType; +import com.codename1.health.WorkoutSample; +import com.codename1.health.sensors.HealthSensor; +import com.codename1.health.sensors.HealthSensorProfile; +import com.codename1.health.sensors.HealthSensors; +import com.codename1.health.sensors.SensorDiscoveryListener; +import com.codename1.health.sensors.SensorScan; +import com.codename1.health.sensors.SensorScanSettings; +import com.codename1.health.sensors.SensorSampleListener; +import com.codename1.health.sensors.SensorSession; +import com.codename1.health.sensors.SensorSessionOptions; +import com.codename1.health.sensors.SensorSessionState; +import com.codename1.health.workout.WorkoutConfiguration; +import com.codename1.health.workout.WorkoutEvent; +import com.codename1.health.workout.WorkoutSession; +import com.codename1.health.workout.WorkoutSessionListener; +import com.codename1.health.workout.WorkoutSessionState; +import com.codenameone.examples.pulse.api.HeartRateDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.WorkoutDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// Pulse and the phone's health store (HealthKit, Health Connect), and a +/// Bluetooth heart rate strap. +/// +/// Two things happen here. Workouts the member recorded elsewhere -- a run, +/// a ride -- are read from the store and posted to the studio, so a friend's +/// feed and the member's own week include them. And during a class the member +/// booked, a workout is recorded: the heart rate goes to the studio every few +/// seconds, which is what the trainer's live view shows, and when the class +/// ends it is written to the store as a workout and posted like the others. +/// +/// Everything arrives on the EDT, so nothing here is guarded. +public final class Workouts { + /// Where a posted workout came from. + public static final String SOURCE_HEALTH = "HEALTH"; + public static final String SOURCE_CLASS = "CLASS"; + /// A class measured by a heart rate strap: the server gives it a bonus. + public static final String SOURCE_STRAP = "STRAP"; + /// A class measured by the watch the platform's workout collects from. + public static final String SOURCE_WATCH = "WATCH"; + /// The heart rate is sent no more often than this. + private static final long SEND_EVERY = 5000L; + + /// Told how a class workout is going. + public interface Progress { + /// A new heart rate reading, in beats per minute. + void beat(int bpm); + + /// The workout ended and was posted, or failed with `error`. + void ended(WorkoutDto posted, String error); + } + + private static WorkoutSession recording; + private static SessionDto recordingClass; + private static SensorScan scan; + private static SensorSession strap; + // What measured the class being recorded: the strap, the watch, or + // nothing worn ([#SOURCE_CLASS]). + private static String measuredBy = SOURCE_CLASS; + private static Progress progress; + private static boolean shared; + private static long lastSent; + private static int beats; + private static long beatTotal; + private static int maxBeat; + + private Workouts() { + } + + /// Whether the phone has a health store Pulse can use. + public static boolean storeAvailable() { + return Health.getInstance().isSupported(); + } + + /// Whether a strap can be paired, which needs no health store. + public static boolean strapsAvailable() { + HealthSensors sensors = Health.getInstance().getSensors(); + return sensors != null && sensors.isSupported(); + } + + /// The class being recorded, or null. + public static SessionDto recording() { + return recordingClass; + } + + /// Reads the last two weeks of workouts from the store and posts those + /// the studio does not have yet. `done` gets how many were posted, or -1 + /// with nothing posted when the store could not be read. + public static void importRecent(final boolean share, final Net.Ok done) { + if (!storeAvailable()) { + done.got(Integer.valueOf(-1)); + return; + } + final HealthStore store = Health.getInstance().getStore(); + store.requestAuthorization(HealthAccess.read(HealthDataType.WORKOUT), + HealthAccess.read(HealthDataType.HEART_RATE)).onResult((asked, refused) -> { + if (refused != null) { + done.got(Integer.valueOf(-1)); + return; + } + SampleQuery query = new SampleQuery().addType(HealthDataType.WORKOUT) + .setTimeRange(HealthTimeRange.lastDays(14)).setLimit(30) + .setSortDescending(true); + store.readSamples(query).onResult((samples, failed) -> { + if (failed != null) { + done.got(Integer.valueOf(-1)); + return; + } + Api.engagement().workouts(Net.to(known -> + post(fresh(samples, known), share, 0, done), + (status, message) -> done.got(Integer.valueOf(-1)))); + }); + }); + } + + /// The workouts of `samples` that start at no time a posted one does. + private static List fresh(List samples, List known) { + List out = new ArrayList(); + for (int iter = 0; samples != null && iter < samples.size(); iter++) { + HealthSample sample = samples.get(iter); + if (!(sample instanceof WorkoutSample)) { + continue; + } + WorkoutSample workout = (WorkoutSample) sample; + boolean seen = false; + for (int k = 0; known != null && k < known.size(); k++) { + if (Math.abs(known.get(k).startedAt() - workout.getStartMillis()) < 60000L) { + seen = true; + break; + } + } + if (!seen) { + out.add(dto(workout, null, SOURCE_HEALTH, 0, 0)); + } + } + return out; + } + + private static void post(final List workouts, final boolean share, + final int index, final Net.Ok done) { + if (index >= workouts.size()) { + done.got(Integer.valueOf(index)); + return; + } + WorkoutDto workout = workouts.get(index); + Api.engagement().addWorkout(shared(workout, share), Net.to(posted -> + post(workouts, share, index + 1, done), (status, message) -> + done.got(Integer.valueOf(index)))); + } + + private static WorkoutDto shared(WorkoutDto workout, boolean share) { + return new WorkoutDto(workout.id(), workout.sessionId(), workout.activity(), + workout.startedAt(), workout.minutes(), workout.avgHr(), workout.maxHr(), + workout.calories(), workout.zones(), workout.source(), share, workout.points()); + } + + private static WorkoutDto dto(WorkoutSample workout, String sessionId, String source, + int avg, int max) { + long millis = workout.getActiveDurationMillis() > 0 ? workout.getActiveDurationMillis() + : workout.getDurationMillis(); + HealthQuantity energy = workout.getTotalEnergy(); + int calories = energy == null ? 0 : (int) Math.round(energy.getValue( + HealthUnit.KILOCALORIE)); + return new WorkoutDto(null, sessionId, activityName(workout.getActivityType()), + workout.getStartMillis(), (int) (millis / 60000L), avg, max, calories, null, + source, false, 0); + } + + private static String activityName(WorkoutActivityType type) { + return type == null ? "OTHER" : type.name(); + } + + /// The kind of workout a class of `category` is. + static WorkoutActivityType activity(String category) { + String kind = category == null ? "" : category; + if (kind.equalsIgnoreCase("cycling") || kind.equalsIgnoreCase("spin")) { + return WorkoutActivityType.CYCLING; + } + if (kind.equalsIgnoreCase("yoga")) { + return WorkoutActivityType.YOGA; + } + if (kind.equalsIgnoreCase("pilates")) { + return WorkoutActivityType.PILATES; + } + if (kind.equalsIgnoreCase("strength")) { + return WorkoutActivityType.STRENGTH_TRAINING; + } + if (kind.equalsIgnoreCase("hiit") || kind.equalsIgnoreCase("cardio")) { + return WorkoutActivityType.HIGH_INTENSITY_INTERVAL_TRAINING; + } + if (kind.equalsIgnoreCase("dance")) { + return WorkoutActivityType.DANCE; + } + return WorkoutActivityType.FUNCTIONAL_TRAINING; + } + + /// Records `session` as a workout, streaming the heart rate to the + /// studio while it runs. A strap is used when one is in range; otherwise + /// the heart rate is whatever the platform's workout collects (a paired + /// watch). Without a store, a strap alone still feeds the live view. + public static void startClass(final SessionDto session, boolean share, Progress listener) { + if (recordingClass != null) { + return; + } + recordingClass = session; + progress = listener; + shared = share; + beats = 0; + measuredBy = SOURCE_CLASS; + beatTotal = 0; + maxBeat = 0; + lastSent = 0; + if (!storeAvailable()) { + pairStrap(null); + return; + } + Health.getInstance().getStore().requestAuthorization( + HealthAccess.write(HealthDataType.WORKOUT), + HealthAccess.read(HealthDataType.HEART_RATE), + HealthAccess.write(HealthDataType.HEART_RATE)).onResult((asked, refused) -> { + WorkoutConfiguration config = new WorkoutConfiguration() + .setActivityType(activity(session.category())) + .setTitle(session.name()) + .addCollectedType(HealthDataType.HEART_RATE); + Health.getInstance().getWorkouts().startSession(config) + .onResult((workout, failed) -> { + if (failed != null || workout == null) { + pairStrap(null); + return; + } + recording = workout; + workout.addListener(WORKOUT_LISTENER); + workout.start(); + pairStrap(workout); + }); + }); + } + + /// Looks for a heart rate strap and, on the first found, feeds it into + /// `workout` (which may be null when there is no store). + private static void pairStrap(final WorkoutSession workout) { + if (!strapsAvailable()) { + return; + } + final HealthSensors sensors = Health.getInstance().getSensors(); + scan = sensors.startScan(new SensorScanSettings().addProfile(HealthSensorProfile.HEART_RATE) + .setTimeoutMillis(20000), new SensorDiscoveryListener() { + @Override + public void sensorDiscovered(HealthSensor sensor) { + if (strap != null || recordingClass == null) { + return; + } + stopScan(); + SensorSessionOptions options = new SensorSessionOptions() + .setAutoReconnect(true); + if (workout != null) { + options.setWorkoutSession(workout); + } + sensors.connect(sensor, HealthSensorProfile.HEART_RATE, options) + .onResult((connected, failed) -> { + if (failed != null || connected == null) { + return; + } + if (recordingClass == null) { + connected.stop(); + return; + } + strap = connected; + connected.addListener(STRAP_LISTENER); + }); + } + + @Override + public void scanFailed(HealthException error) { + scan = null; + } + }); + } + + private static void stopScan() { + if (scan != null) { + scan.stop(); + scan = null; + } + } + + /// A reading, from a strap or the workout. Sent on to the studio at most + /// every [#SEND_EVERY] milliseconds. + static void beat(int bpm) { + if (recordingClass == null || bpm <= 0) { + return; + } + beats++; + beatTotal += bpm; + maxBeat = Math.max(maxBeat, bpm); + if (progress != null) { + progress.beat(bpm); + } + long now = System.currentTimeMillis(); + if (now - lastSent < SEND_EVERY) { + return; + } + lastSent = now; + Api.engagement().heartRate(new HeartRateDto(recordingClass.id(), bpm), + Net.to(sent -> { }, (status, message) -> { })); + } + + /// Ends the class workout: writes it to the store and posts it. + public static void stopClass() { + final SessionDto session = recordingClass; + if (session == null) { + return; + } + stopScan(); + if (strap != null) { + strap.removeListener(STRAP_LISTENER); + strap.stop(); + strap = null; + } + final int avg = beats == 0 ? 0 : (int) (beatTotal / beats); + final int max = maxBeat; + final WorkoutSession workout = recording; + recording = null; + if (workout == null) { + finish(new WorkoutDto(null, session.id(), activityName(activity(session.category())), + session.startsAt(), (int) ((Math.min(System.currentTimeMillis(), + session.endsAt()) - session.startsAt()) / 60000L), avg, max, 0, null, + measuredBy, shared, 0)); + return; + } + workout.removeListener(WORKOUT_LISTENER); + workout.end().onResult((written, failed) -> { + if (failed != null || written == null) { + finish(new WorkoutDto(null, session.id(), activityName(activity( + session.category())), workout.getStartedAtMillis(), + (int) (workout.getElapsedMillis() / 60000L), avg, max, 0, null, + measuredBy, shared, 0)); + return; + } + finish(shared(dto(written, session.id(), measuredBy, avg, max), shared)); + }); + } + + private static void finish(WorkoutDto workout) { + final Progress told = progress; + recordingClass = null; + progress = null; + Api.engagement().addWorkout(workout, Net.to(posted -> { + if (told != null) { + told.ended(posted, null); + } + }, (status, message) -> { + if (told != null) { + told.ended(null, message); + } else { + Ui.fail(status, message); + } + })); + } + + /// The class booked for the member that is on now, starting within a + /// quarter of an hour or under way, or null. + public static SessionDto classNow(List booked, long at) { + for (int iter = 0; booked != null && iter < booked.size(); iter++) { + SessionDto session = booked.get(iter); + if (session != null && session.startsAt() - 15 * 60000L <= at + && session.endsAt() > at) { + return session; + } + } + return null; + } + + private static final SensorSampleListener STRAP_LISTENER = new SensorSampleListener() { + @Override + public void sensorSample(SensorSession session, HealthSample sample) { + if (sample instanceof QuantitySample + && HealthDataType.HEART_RATE.equals(sample.getType())) { + measuredBy = SOURCE_STRAP; + beat((int) Math.round(((QuantitySample) sample).getValue( + HealthUnit.COUNT_PER_MINUTE))); + } + } + + @Override + public void sensorStateChanged(SensorSession session, SensorSessionState state) { + } + + @Override + public void sensorError(SensorSession session, HealthException error) { + } + }; + + private static final WorkoutSessionListener WORKOUT_LISTENER = new WorkoutSessionListener() { + @Override + public void workoutStateChanged(WorkoutSession session, WorkoutSessionState state) { + } + + @Override + public void workoutStatisticsUpdated(WorkoutSession session, HealthDataType type) { + // A strap reports each beat itself. Without one the platform + // collects the heart rate (a paired watch) and keeps running + // statistics rather than the latest beat, so the running average + // is what the live view gets. + if (strap != null || !HealthDataType.HEART_RATE.equals(type)) { + return; + } + HealthQuantity average = session.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.AVERAGE); + if (average != null) { + measuredBy = SOURCE_WATCH; + beat((int) Math.round(average.getValue(HealthUnit.COUNT_PER_MINUTE))); + } + } + + @Override + public void workoutEvent(WorkoutSession session, WorkoutEvent event) { + } + + @Override + public void workoutFailed(WorkoutSession session, HealthException error) { + } + }; +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/live/LiveChannel.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/live/LiveChannel.java new file mode 100644 index 00000000000..090aad2065a --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/live/LiveChannel.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.live; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.JSONParser; +import com.codename1.io.WebSocket; +import com.codename1.io.WebSocketState; +import com.codename1.ui.CN; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Nav; + +import java.io.IOException; +import java.util.Map; +import java.util.Timer; + +/// The WebSocket the server pushes changes down: a class filled up, a booking +/// moved off the waitlist, a member shared a heart rate, someone came in. +/// +/// It only ever says *that* something changed. What the booking now is, the app +/// asks for over the REST API, which stays the one source of truth -- so a +/// frame that is lost, or a socket that is down, costs a few seconds and +/// nothing else. Every screen that listens here also polls, slowly while the +/// socket is up and faster while it is not ([#connected] says which). +/// +/// A WebSocket handshake cannot carry the bearer token on every platform, so +/// the connection is let in by a ticket: the app asks for one over the +/// authenticated API, and the server accepts it once, within half a minute. +public final class LiveChannel { + /// What a screen wants to hear about. All of it arrives on the EDT. + public interface Listener { + /// A class filled up, emptied or was cancelled. + void sessionChanged(String id, int booked, int waitlisted, String state); + + /// One of this account's places changed: read the bookings again. + void bookingChanged(String id, String state); + + /// A member in a class this trainer runs shared a heart rate reading. + void heartRate(String sessionId, String memberId, int bpm, int zone); + + /// Someone came through a door; staff only. + void checkedIn(String locationId, String name, boolean admitted); + } + + /// A [Listener] that ignores everything, to override what a screen needs. + public static class Adapter implements Listener { + @Override + public void sessionChanged(String id, int booked, int waitlisted, String state) { + } + + @Override + public void bookingChanged(String id, String state) { + } + + @Override + public void heartRate(String sessionId, String memberId, int bpm, int zone) { + } + + @Override + public void checkedIn(String locationId, String name, boolean admitted) { + } + } + + private static final int PING_MILLIS = 25000; + + private static WebSocket socket; + private static Listener listener; + private static boolean wanted; + private static boolean connected; + private static int attempts; + /// Counts the connections made, so that what a closed or replaced one + /// still has to say -- its `onClose`, a frame already on its way -- is + /// recognised as stale and dropped. + private static int generation; + private static Timer pinger; + + private LiveChannel() { + } + + /// Connects, and keeps reconnecting until [#stop]. + public static void start() { + if (wanted) { + return; + } + wanted = true; + attempts = 0; + connect(); + } + + public static void stop() { + wanted = false; + connected = false; + listener = null; + generation++; + if (pinger != null) { + pinger.cancel(); + pinger = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } + + /// The one screen that is listening: the one on display. + public static void listen(Listener current) { + listener = current; + } + + public static boolean connected() { + return connected; + } + + private static void connect() { + if (!wanted || !WebSocket.isSupported()) { + return; + } + final int mine = ++generation; + Api.account().liveTicket(Net.to(ticket -> { + if (mine == generation && wanted) { + open(ticket.ticket(), mine); + } + }, (status, message) -> lost(mine))); + } + + private static void open(String ticket, final int mine) { + String base = Session.serverUrl(); + // http -> ws and https -> wss: the scheme keeps its "s". + String url = "ws" + base.substring(4) + "/ws/live?ticket=" + ticket; + // The handlers run on the socket's thread, so each hands its news to + // the EDT, where the rest of the app lives. + socket = WebSocket.build(url) + .onConnect(ws -> CN.callSerially(() -> opened(mine))) + .onTextMessage((ws, text) -> CN.callSerially(() -> frame(text, mine))) + .onClose((ws, code, reason) -> CN.callSerially(() -> closed(mine, code))) + .onError((ws, err) -> CN.callSerially(() -> lost(mine))) + .connect(10000); + } + + private static void opened(int mine) { + if (mine != generation) { + return; + } + connected = true; + attempts = 0; + if (pinger != null) { + pinger.cancel(); + } + // Something has to cross an idle connection now and then, or a proxy + // on the way decides it is dead. + pinger = CN.setInterval(PING_MILLIS, () -> { + if (mine == generation && socket != null + && socket.getReadyState() == WebSocketState.OPEN) { + socket.send("{\"type\":\"ping\"}"); + } + }); + } + + /// The server closes with this code when the account's roles or standing + /// changed: an admin withdrew a mode, or blocked the account. + private static final int CHANGED = 4403; + + private static void closed(int mine, int code) { + if (code == CHANGED && mine == generation && wanted) { + // The account the app has cached is no longer the account the + // server knows: ask again, and go back home if the answer opens + // another mode, so a withdrawn mode is not left open and refused + // at every request. Reconnecting below happens as for any loss. + Session.refreshUser(Nav::refreshed, (status, message) -> { }); + } + lost(mine); + } + + private static void lost(int mine) { + if (mine != generation || !wanted) { + return; + } + connected = false; + final int waiting = ++generation; + if (socket != null) { + socket.close(); + socket = null; + } + // 1, 2, 4 ... 32 seconds, and then every 32. + int delay = 1000 << Math.min(attempts++, 5); + // Only if nothing has happened to the channel in the meantime. Stopped + // and started again during the wait, it has a connection of its own + // by now, and connecting here as well would take that one's place: + // its callbacks would go unheard and its socket would be left open. + CN.setTimeout(delay, () -> CN.callSerially(() -> { + if (waiting == generation) { + connect(); + } + })); + } + + private static void frame(String text, int mine) { + if (mine != generation || listener == null) { + return; + } + Map json; + try { + json = new JSONParser().parseJSON(new CharArrayReader(text.toCharArray())); + } catch (IOException malformed) { + return; + } + Object type = json.get("type"); + if ("session".equals(type)) { + listener.sessionChanged(text(json, "id"), (int) number(json, "booked"), + (int) number(json, "waitlisted"), text(json, "state")); + } else if ("booking".equals(type)) { + listener.bookingChanged(text(json, "id"), text(json, "state")); + } else if ("heartrate".equals(type)) { + listener.heartRate(text(json, "sessionId"), text(json, "memberId"), + (int) number(json, "bpm"), (int) number(json, "zone")); + } else if ("checkin".equals(type)) { + Object admitted = json.get("admitted"); + listener.checkedIn(text(json, "locationId"), text(json, "name"), + Boolean.TRUE.equals(admitted) || "true".equals(admitted)); + } + } + + // Tested, never cast and caught: a failed cast does not throw on iOS. + private static String text(Map json, String key) { + Object value = json.get(key); + return value instanceof String ? (String) value : ""; + } + + private static double number(Map json, String key) { + Object value = json.get(key); + return value instanceof Number ? ((Number) value).doubleValue() : 0; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/AwayForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/AwayForm.java new file mode 100644 index 00000000000..3d62fb3d228 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/AwayForm.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.AwayDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Saying you will not be in: travelling, injured, or anything else. +/// +/// While an away period runs the server sends no "we miss you" nudges, keeps +/// the streak from breaking, and -- when the member asks -- freezes billing +/// for the plan, as a freeze would. +public final class AwayForm { + private AwayForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Away", "Away"); + Ui.back(form, previous); + final Container list = Ui.list("away"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.engagement().away(Net.to(found -> fill(form, list, found, load[0]), + Ui.failed(list, load[0]))); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("I'll be away", "addAway", + e -> add(form, load[0])))); + form.show(); + load[0].run(); + } + + private static String reason(String reason) { + return "TRAVEL".equals(reason) ? Lang.tr("Travelling") + : "INJURY".equals(reason) ? Lang.tr("Injured") : Lang.tr("Away"); + } + + private static void fill(Form form, Container list, List periods, + final Runnable load) { + list.removeAll(); + if (periods == null || periods.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_BEACH_ACCESS, Lang.tr("No time away"), + Lang.tr("Going somewhere? Tell us and we will not nag you."))); + } + for (int iter = 0; periods != null && iter < periods.size(); iter++) { + final AwayDto period = periods.get(iter); + list.add(Ui.row(FontImage.MATERIAL_BEACH_ACCESS, reason(period.reason()), + period.startsOn() + " - " + period.endsOn() + + (period.freezeBilling() ? "\n" + Lang.tr("Billing paused") : ""), + Ui.flat(FontImage.MATERIAL_DELETE_OUTLINE, "Remove", "removeAway-" + iter, + e -> Api.engagement().removeAway(period.id(), + Net.to(gone -> load.run(), Ui::fail))))); + } + Ui.refresh(form); + } + + private static void add(final Form previous, final Runnable added) { + final Form form = Ui.form("Time away", "AddAway"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField from = Ui.field(page, "From", "from", "YYYY-MM-DD"); + final TextField until = Ui.field(page, "Until", "until", "YYYY-MM-DD"); + final String[] reason = {"TRAVEL"}; + page.add(Ui.label("Why", "SvLabel")); + page.add(Ui.choice("reason", new String[] {Lang.tr("Travel"), Lang.tr("Injury"), + Lang.tr("Other")}, new String[] {"TRAVEL", "INJURY", "OTHER"}, reason[0], + value -> reason[0] = value)); + final boolean[] freeze = {false}; + page.add(Ui.row(FontImage.MATERIAL_PAUSE_CIRCLE, Lang.tr("Pause billing"), + Lang.tr("Freeze the plan for these dates"), + Ui.toggle("freezeBilling", false, on -> freeze[0] = on))); + page.add(Ui.primary("Save", "submit", e -> Api.engagement().addAway(new AwayDto(null, + from.getText().trim(), until.getText().trim(), reason[0], freeze[0]), + Net.to(saved -> { + added.run(); + previous.showBack(); + }, Ui::fail)))); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/BookingsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/BookingsForm.java new file mode 100644 index 00000000000..c98ad6c43a3 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/BookingsForm.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The household's places: what is coming up, with a way to cancel it or put +/// it in the calendar, and what was attended before. +public final class BookingsForm { + private BookingsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("My bookings", "Bookings"); + Ui.back(form, previous); + final Container list = Ui.list("bookings"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.bookings().upcoming(Net.to(upcoming -> + Api.bookings().history(Net.to(history -> { + fill(form, list, upcoming, history, load[0]); + }, Ui.failed(list, load[0]))), Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(Form form, Container list, List upcoming, + List history, final Runnable load) { + list.removeAll(); + Ui.section(list, "Coming up"); + int shown = 0; + for (int iter = 0; upcoming != null && iter < upcoming.size(); iter++) { + final BookingDto booking = upcoming.get(iter); + if (booking.session() == null || Classes.CANCELLED.equals(booking.state())) { + continue; + } + Container card = Classes.card(booking.session(), + Classes.bookedPill(booking.session(), booking, load), + Classes.forWhom(booking)); + card.setName("booking-" + iter); + list.add(card); + shown++; + } + if (shown == 0) { + list.add(Ui.text("Nothing booked.", "SvMuted")); + } + Ui.section(list, "History"); + for (int iter = 0; history != null && iter < history.size(); iter++) { + BookingDto booking = history.get(iter); + if (booking.session() == null) { + continue; + } + list.add(Ui.row(Classes.CHECKED_IN.equals(booking.state()) + ? FontImage.MATERIAL_CHECK_CIRCLE : FontImage.MATERIAL_SCHEDULE, + booking.session().name(), Ui.when(booking.session().startsAt()) + " - " + + booking.memberName(), null)); + } + if (history == null || history.isEmpty()) { + list.add(Ui.text("No classes attended yet.", "SvMuted")); + } + Ui.refresh(form); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Classes.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Classes.java new file mode 100644 index 00000000000..467bff5a083 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Classes.java @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.calendar.CalendarAccess; +import com.codename1.calendar.CalendarAlarm; +import com.codename1.calendar.CalendarAuthorizationStatus; +import com.codename1.calendar.CalendarDateTime; +import com.codename1.calendar.CalendarEvent; +import com.codename1.calendar.CalendarMutationScope; +import com.codename1.calendar.LocalCalendarSource; +import com.codename1.ui.Button; +import com.codename1.ui.Sheet; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.ui.Chart; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.BookRequestDto; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; + +/// What every screen that shows a class does with it: books it, cancels it, +/// puts it in the device's calendar, and says how it stands. +/// +/// The server decides whether a booking is a place or a place in the queue, +/// and whether the member is old enough, young enough or paid up enough to +/// have either; nothing is checked here first, so the reason the server gives +/// is the one the member reads. +public final class Classes { + public static final String BOOKED = "BOOKED"; + public static final String WAITLISTED = "WAITLISTED"; + public static final String CANCELLED = "CANCELLED"; + public static final String CHECKED_IN = "CHECKED_IN"; + + /// Age bands for the timetable filter. + public static final String BAND_ALL = "all"; + public static final String BAND_KIDS = "kids"; + public static final String BAND_TEENS = "teens"; + public static final String BAND_ADULTS = "adults"; + + private Classes() { + } + + /// Books `member` into `session`; a full class puts them in its queue. + public static void book(SessionDto session, MemberDto member, final Runnable done) { + book(session, member, done, null); + } + + /// The same, with `failed` run after a refusal has been shown, so the + /// caller can offer the button again. + public static void book(SessionDto session, MemberDto member, final Runnable done, + final Runnable failed) { + final boolean child = member != null && "CHILD".equals(member.relation()); + Api.bookings().book(new BookRequestDto(session.id(), member == null ? null : member.id(), + null), Net.to(booking -> { + boolean queued = WAITLISTED.equals(booking.state()); + Telemetry.booked(queued, child); + Ui.say(queued ? Lang.tr("Class is full. You are number {0} in the queue", + Integer.valueOf(booking.position())) : Lang.tr("Booked")); + Reminders.refresh(); + done.run(); + }, (status, message) -> { + Ui.fail(status, message); + if (failed != null) { + failed.run(); + } + })); + } + + public static void cancel(BookingDto booking, final Runnable done) { + Api.bookings().cancel(booking.id(), Net.to(cancelled -> { + Telemetry.bookingCancelled(); + Ui.say("Cancelled"); + Reminders.refresh(); + done.run(); + }, Ui::fail)); + } + + /// The booking `member` holds in `session`, or null. + public static BookingDto bookingOf(List bookings, String sessionId, + String memberId) { + for (int iter = 0; bookings != null && iter < bookings.size(); iter++) { + BookingDto booking = bookings.get(iter); + if (sessionId.equals(booking.sessionId()) && memberId != null + && memberId.equals(booking.memberId()) + && !CANCELLED.equals(booking.state())) { + return booking; + } + } + return null; + } + + /// Whether `session` is for someone of `age`. A maximum of 0 is no maximum. + public static boolean fits(SessionDto session, int age) { + return age >= session.minAge() && (session.maxAge() <= 0 || age <= session.maxAge()); + } + + /// Whether `session` belongs in the age band the timetable is filtered to. + public static boolean inBand(SessionDto session, String band) { + if (BAND_KIDS.equals(band)) { + return session.maxAge() > 0 && session.maxAge() <= 12; + } + if (BAND_TEENS.equals(band)) { + return session.minAge() >= 13 && session.maxAge() > 0 && session.maxAge() <= 17; + } + if (BAND_ADULTS.equals(band)) { + return session.minAge() >= 18 || session.maxAge() <= 0 && session.minAge() >= 16; + } + return true; + } + + /// Places left, or the queue, in words. + public static String places(SessionDto session) { + int left = session.capacity() - session.booked(); + if ("CANCELLED".equals(session.state())) { + return Lang.tr("Cancelled by the studio"); + } + if (left > 0) { + return Lang.tr("{0} of {1} places left", Integer.valueOf(left), + Integer.valueOf(session.capacity())); + } + return Lang.tr("Full, {0} waiting", Integer.valueOf(session.waitlisted())); + } + + /// A class as a card: the time in its own column, a stripe in the class + /// type's colour, the name with its kind of training, who teaches it and + /// where, how full it is, and `action` -- a Book button or how the member + /// stands -- at the end. + public static Container card(SessionDto session, Component action) { + return card(session, action, null); + } + + /// The first name of whoever `booking` is for, or null when it is the + /// member's own. + public static String forWhom(BookingDto booking) { + UserDto user = Session.user(); + String name = booking.memberName(); + if (name == null || user != null && name.equals(user.displayName())) { + return null; + } + int space = name.indexOf(' '); + return space > 0 ? name.substring(0, space) : name; + } + + /// The same card for a booking made for someone else in the household. + /// + /// @param who the first name of the one it is for, or null for the member + public static Container card(SessionDto session, Component action, String who) { + return card(session, action, who, null); + } + + /// The card, with where it is beside who teaches it, for those who see + /// every location's classes. + public static Container card(SessionDto session, Component action, String who, + String where) { + int color = color(session.color()); + Container card = new Container(new BorderLayout()); + card.setUIID("SvClass"); + card.setName("class-" + session.id()); + + Container time = new Container(BoxLayout.y()); + time.setUIID("SvClassTime"); + time.add(new Label(clock(session.startsAt()), "SvClassClock")); + time.add(new Label(Lang.tr("{0} min", Integer.valueOf( + (int) ((session.endsAt() - session.startsAt()) / 60000L))), "SvClassLength")); + // The kind of class sits under its time, which leaves the name the + // whole width of the card's middle. + Label glyph = new Label("", "SvClassGlyph"); + glyph.getAllStyles().setFgColor(color); + glyph.setIcon(FontImage.createMaterial(glyph(session.category()), + glyph.getUnselectedStyle(), 3.2f)); + glyph.setGap(0); + time.add(glyph); + Label stripe = new Label("", "SvClassStripe"); + stripe.getAllStyles().setBgColor(color); + stripe.getAllStyles().setBgTransparency(255); + stripe.setPreferredW(com.codename1.ui.CN.convertToPixels(0.9f)); + Container west = new Container(new BorderLayout()); + west.add(BorderLayout.CENTER, time).add(BorderLayout.EAST, stripe); + card.add(BorderLayout.WEST, west); + + Container body = new Container(BoxLayout.y()); + body.setUIID("SvClassBody"); + body.add(new com.codename1.components.SpanLabel(session.name(), "SvClassName")); + body.add(new Label(nullToEmpty(session.trainerName()), "SvClassWho")); + if (where != null) { + body.add(new Label(where, "SvClassWho")); + } + if (who != null) { + body.add(new Label(Lang.tr("for {0}", who), "SvClassWho")); + } + if ("CANCELLED".equals(session.state())) { + body.add(new Label(Lang.tr("Cancelled by the studio"), "SvClassFull")); + } else { + int left = session.capacity() - session.booked(); + Chart.Meter meter = new Chart.Meter(session.capacity() <= 0 ? 0 + : session.booked() / (double) session.capacity()); + meter.getAllStyles().setFgColor(color); + Container fill = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER)); + fill.setUIID("SvClassFill"); + fill.add(BorderLayout.CENTER, meter); + fill.add(BorderLayout.EAST, new Label(left > 0 + ? Lang.tr("{0} left", Integer.valueOf(left)) + : session.waitlisted() > 0 + ? Lang.tr("{0} waiting", Integer.valueOf(session.waitlisted())) + : Lang.tr("Full"), + left > 0 && left <= 3 ? "SvClassScarce" : left > 0 ? "SvClassLeft" + : "SvClassFull")); + body.add(fill); + } + if (session.friendsGoing() > 0) { + // Short: it shares the line's width with the Book button, and + // the group icon says the rest. + Label friends = new Label(session.friendsGoing() == 1 ? Lang.tr("1 friend") + : Lang.tr("{0} friends", Integer.valueOf(session.friendsGoing())), + "SvClassFriends"); + Ui.icon(friends, FontImage.MATERIAL_GROUP, 2.6f); + body.add(friends); + } + card.add(BorderLayout.CENTER, body); + if (action != null) { + card.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(action)); + } + return card; + } + + private static String clock(long millis) { + java.time.LocalTime time = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) + .toLocalTime(); + return (time.getHour() < 10 ? "0" : "") + time.getHour() + ":" + + (time.getMinute() < 10 ? "0" : "") + time.getMinute(); + } + + /// How `booking` stands, as a pill; a tap on it offers what a member does + /// with a booking -- put it in the calendar or cancel it. + public static Button bookedPill(final SessionDto session, final BookingDto booking, + final Runnable changed) { + final Button pill = new Button(WAITLISTED.equals(booking.state()) + ? Lang.tr("Queue #{0}", Integer.valueOf(booking.position())) + : Lang.tr("Booked"), WAITLISTED.equals(booking.state()) + ? "SvPillQueue" : "SvPillBooked"); + pill.setName("booked-" + session.id()); + pill.addActionListener(e -> { + final Sheet sheet = new Sheet(null, session.name()); + Container content = sheet.getContentPane(); + content.setLayout(BoxLayout.y()); + content.add(Ui.text(detail(session), "SvMuted")); + content.add(Ui.secondary("Add to calendar", "calendar-" + session.id(), ev -> { + sheet.back(); + addToCalendar(session); + })); + content.add(Ui.link("Cancel this booking", "cancel-" + session.id(), ev -> { + sheet.back(); + cancel(booking, changed); + })); + sheet.show(); + }); + return pill; + } + + /// The icon for a kind of training. + /// The kind of class as a small round tile: its glyph in its colour, on + /// a pale wash of the same colour. Lists of classes and templates lead + /// with it where a member's card has its stripe. + public static Label tile(String category, String hex) { + int color = color(hex); + Label tile = new Label("", "SvClassTile"); + tile.getAllStyles().setFgColor(color); + tile.setIcon(FontImage.createMaterial(glyph(category), tile.getUnselectedStyle(), 3.4f)); + // No words beside the icon, so no gap to throw it off the middle. + tile.setGap(0); + tile.getAllStyles().setAlignment(Label.CENTER); + int wash = mix(color, 0xffffff, 0.84f); + tile.getAllStyles().setBorder(com.codename1.ui.plaf.RoundBorder.create().color(wash) + .rectangle(false)); + return tile; + } + + private static int mix(int a, int b, float share) { + int r = (int) (((a >> 16) & 0xff) * (1 - share) + ((b >> 16) & 0xff) * share); + int g = (int) (((a >> 8) & 0xff) * (1 - share) + ((b >> 8) & 0xff) * share); + int bl = (int) ((a & 0xff) * (1 - share) + (b & 0xff) * share); + return (r << 16) | (g << 8) | bl; + } + + public static char glyph(String category) { + if ("MIND".equals(category)) { + return FontImage.MATERIAL_SELF_IMPROVEMENT; + } + if ("CARDIO".equals(category)) { + return FontImage.MATERIAL_DIRECTIONS_BIKE; + } + if ("KIDS".equals(category)) { + return FontImage.MATERIAL_CHILD_CARE; + } + return FontImage.MATERIAL_FITNESS_CENTER; + } + + /// A `#rrggbb` colour from the server, or the brand's when it is not one. + public static int color(String hex) { + if (hex != null && hex.length() == 7 && hex.charAt(0) == '#') { + int value = 0; + for (int iter = 1; iter < 7; iter++) { + int digit = Character.digit(hex.charAt(iter), 16); + if (digit < 0) { + return 0xdb3416; + } + value = value * 16 + digit; + } + return value; + } + return 0xdb3416; + } + + /// The trainer, room and time of `session` on one line. + public static String detail(SessionDto session) { + return Ui.when(session.startsAt()) + " - " + nullToEmpty(session.trainerName()) + + (session.roomName() == null ? "" : ", " + session.roomName()); + } + + /// Writes `session` into the device's calendar, with an alarm half an hour + /// before. The device asks the user's permission the first time. + public static void addToCalendar(final SessionDto session) { + final LocalCalendarSource calendar = LocalCalendarSource.getInstance(); + if (!calendar.isAvailable()) { + Ui.say("This device has no calendar to add to"); + return; + } + calendar.requestAuthorization(CalendarAccess.EVENTS_WRITE_ONLY).ready(status -> { + if (status != CalendarAuthorizationStatus.FULL + && status != CalendarAuthorizationStatus.WRITE_ONLY) { + Ui.say("Calendar access was not allowed"); + return; + } + ZoneId zone = ZoneId.systemDefault(); + CalendarEvent event = new CalendarEvent() + .setTitle(session.name()) + .setLocation(nullToEmpty(session.locationName())) + .setDescription(Lang.tr("With {0}", nullToEmpty(session.trainerName()))) + .setStart(CalendarDateTime.instant(Instant.ofEpochMilli(session.startsAt()), + zone)) + .setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(session.endsAt()), zone)) + .addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(30))); + calendar.saveEvent(event, CalendarMutationScope.THIS_INSTANCE).ready(saved -> { + Telemetry.addedToCalendar(); + Ui.say("Added to your calendar"); + }).except(failed -> Ui.say("Could not add it to your calendar")); + }).except(failed -> Ui.say("Could not add it to your calendar")); + } + + static String nullToEmpty(String text) { + return text == null ? "" : text; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/HouseholdForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/HouseholdForm.java new file mode 100644 index 00000000000..0c5cdb1cc73 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/HouseholdForm.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.NewMemberDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The account holder and the children they book for. +/// +/// A child is booked by their guardian, into the classes of their age band, +/// and needs a signed waiver before their first class -- the guardian signs it +/// here. Each child has a pass of their own, for a child who comes in without +/// the guardian's phone. +public final class HouseholdForm { + private HouseholdForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Household", "Household"); + Ui.back(form, previous); + final Container list = Ui.list("household"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.household().members(Net.to(members -> fill(form, list, members, + load[0]), Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("Add a child", "addChild", + e -> add(form, load[0])))); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List members, + final Runnable load) { + list.removeAll(); + for (int iter = 0; members != null && iter < members.size(); iter++) { + final MemberDto member = members.get(iter); + boolean child = "CHILD".equals(member.relation()); + Component end; + if (member.waiverSigned()) { + end = Ui.flat(FontImage.MATERIAL_QR_CODE, + Lang.tr("Pass for {0}", member.name()), "pass-" + iter, + e -> PassForm.show(form, member.id())); + } else { + Button sign = new Button(Lang.tr("Sign waiver"), "SvPillQueue"); + sign.setName("waiver-" + iter); + sign.addActionListener(e -> Api.household().signWaiver(member.id(), + Net.to(signed -> load.run(), Ui::fail))); + end = sign; + } + Container row = Ui.row(child ? FontImage.MATERIAL_CHILD_CARE + : FontImage.MATERIAL_PERSON, member.name(), + Lang.tr("Age {0}", Integer.valueOf(member.age())) + + (member.planName() == null ? "" : " \u00b7 " + member.planName()) + + (member.waiverSigned() ? "" : "\n" + Lang.tr("Waiver needed before class")), + end); + row.setName("member-" + iter); + list.add(member.waiverSigned() ? Ui.tap(row, "open-" + iter, + e -> PassForm.show(form, member.id())) : row); + } + Ui.refresh(form); + } + + private static void add(final Form previous, final Runnable added) { + final Form form = Ui.form("Add a child", "AddChild"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", "Their first and last name"); + final TextField birth = Ui.field(page, "Date of birth", "birthDate", "YYYY-MM-DD"); + final TextField medical = Ui.field(page, "Anything the coach should know", + "medical", "Allergies, asthma, an injury - optional"); + page.add(Ui.text("Kids' classes are by age, so the timetable shows them the classes " + + "they can join. You sign their waiver next.", "SvMuted")); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button save = Ui.primary("Add", "submit", null); + save.addActionListener(e -> { + save.setEnabled(false); + Api.household().addChild(new NewMemberDto(name.getText().trim(), + birth.getText().trim(), medical.getText().trim()), Net.to(child -> { + added.run(); + previous.showBack(); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(error).add(save); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MemberForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MemberForm.java new file mode 100644 index 00000000000..fed53df91a6 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MemberForm.java @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.ui.Level; +import com.codenameone.examples.pulse.ui.Beat; +import com.codenameone.examples.pulse.ui.Badges; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Label; +import com.codename1.ui.Button; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.MembershipDto; +import com.codenameone.examples.pulse.api.StatsDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.watch.WatchBridge; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Chart; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Menu; +import com.codenameone.examples.pulse.ui.Nav; +import com.codenameone.examples.pulse.ui.SettingsForm; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The member's home: the next class, how the training is going, and where the +/// membership stands -- with the timetable and the pass one tap away. +/// +/// Three reads fill it, each into its own card, so a slow one does not hold +/// the others back. A booking that changes on the server (a place freed up in +/// a queue, a class the studio cancelled) arrives over the live channel and +/// the cards are read again. +public class MemberForm extends LiveChannel.Adapter implements Nav.Home { + private final Form form = Ui.home("Member"); + private final Menu menu = new Menu(form, Nav.MEMBER); + private final Container next = Ui.card(); + private final Container stats = Ui.card(); + private final Container plan = Ui.card(); + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + menu.item(FontImage.MATERIAL_HOME, "Home", "home", () -> form.showBack()); + menu.item(FontImage.MATERIAL_EVENT, "Timetable", "schedule", + () -> ScheduleForm.show(form)); + menu.item(FontImage.MATERIAL_EVENT_AVAILABLE, "My bookings", "bookings", + () -> BookingsForm.show(form)); + menu.item(FontImage.MATERIAL_QR_CODE, "Membership pass", "pass", + () -> PassForm.show(form, null)); + menu.item(FontImage.MATERIAL_FAMILY_RESTROOM, "Household", "household", + () -> HouseholdForm.show(form)); + menu.item(FontImage.MATERIAL_CARD_MEMBERSHIP, "Membership", "membership", + () -> MembershipForm.show(form)); + menu.item(FontImage.MATERIAL_BEACH_ACCESS, "Away", "away", () -> AwayForm.show(form)); + menu.item(FontImage.MATERIAL_INBOX, "Inbox", "nudges", () -> NudgesForm.show(form)); + menu.item(FontImage.MATERIAL_GROUP, "Friends", "social", () -> SocialForm.show(form)); + menu.item(FontImage.MATERIAL_DIRECTIONS_RUN, "Workouts", "workouts", + () -> WorkoutsForm.show(form)); + + UserDto user = Session.user(); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, menu.flatButton()); + bar.add(BorderLayout.CENTER, Ui.label(Lang.tr(greeting(), + user == null ? "" : first(user.displayName())), "SvTitle")); + bar.add(BorderLayout.EAST, Ui.flat(FontImage.MATERIAL_QR_CODE, "Membership pass", "home-pass", + e -> PassForm.show(form, null))); + + Container page = Ui.page(); + page.setName("home"); + next.setName("next"); + stats.setName("stats"); + plan.setName("plan"); + page.add(next).add(stats).add(plan); + next.setUIID("SvInk"); + Container quick = new Container(new com.codename1.ui.layouts.GridLayout(1, 4)); + quick.add(tile(FontImage.MATERIAL_EVENT, "Timetable", "home-book", + () -> ScheduleForm.show(form))); + quick.add(tile(FontImage.MATERIAL_QR_CODE, "Pass", "home-pass-tile", + () -> PassForm.show(form, null))); + quick.add(tile(FontImage.MATERIAL_GROUP, "Friends", "home-friends", + () -> SocialForm.show(form))); + quick.add(tile(FontImage.MATERIAL_DIRECTIONS_RUN, "Workouts", "home-workouts", + () -> WorkoutsForm.show(form))); + page.removeAll(); + page.add(next).add(quick).add(stats).add(plan); + page.add(Ui.link("Settings", "home-settings", e -> SettingsForm.show(form))); + Ui.pull(page, this::load); + + Container shell = new Container(new BorderLayout()); + shell.add(BorderLayout.NORTH, bar); + shell.add(BorderLayout.CENTER, page); + form.add(shell); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + load(); + }); + } + + @Override + public void bookingChanged(String id, String state) { + load(); + } + + private void load() { + Api.bookings().upcoming(Net.to(this::showNext, Ui::fail)); + Api.engagement().stats(Net.to(this::showStats, Ui::fail)); + Api.membership().memberships(Net.to(this::showPlan, Ui::fail)); + // The watch shows the next class and the door code; it is told again + // whenever the home is, which is after anything that changes either. + WatchBridge.publish(); + } + + private void showNext(List bookings) { + if (!Nav.isHome(form)) { + return; + } + Reminders.sync(bookings); + next.removeAll(); + next.setLayout(BoxLayout.y()); + BookingDto first = null; + for (int iter = 0; bookings != null && iter < bookings.size(); iter++) { + BookingDto booking = bookings.get(iter); + if (booking.session() != null && !Classes.CANCELLED.equals(booking.state()) + && (first == null + || booking.session().startsAt() < first.session().startsAt())) { + first = booking; + } + } + Container head = new Container(new BorderLayout(), "SvInkRow"); + head.add(BorderLayout.CENTER, Ui.label("NEXT CLASS", "SvInkEyebrow")); + Beat beat = new Beat("SvBeat", true); + beat.setPreferredW(com.codename1.ui.CN.convertToPixels(14)); + beat.setPreferredH(com.codename1.ui.CN.convertToPixels(5)); + head.add(BorderLayout.EAST, beat); + next.add(head); + if (first == null) { + next.add(Ui.label("Nothing booked yet", "SvInkTitle")); + next.add(Ui.text("Pick a class and your pass and reminder are ready.", + "SvInkText")); + Button find = new Button(Lang.tr("Find a class"), "SvInkButton"); + find.setName("next-find"); + find.addActionListener(e -> ScheduleForm.show(form)); + next.add(FlowLayout.encloseIn(find)); + } else { + final BookingDto booking = first; + SessionDto session = booking.session(); + next.add(Ui.text(session.name(), "SvInkTitle")); + // Two short lines rather than one that runs off a narrow card. + next.add(Ui.plain(Ui.when(session.startsAt()), "SvInkText")); + next.add(Ui.plain((session.trainerName() == null ? "" : session.trainerName()) + + (session.roomName() == null ? "" : Ui.DOT + session.roomName()), + "SvInkText")); + Container chips = new Container(new FlowLayout(), "SvInkRow"); + chips.add(Ui.plain(until(session.startsAt()), "SvInkChip")); + if (Classes.WAITLISTED.equals(booking.state())) { + chips.add(Ui.label(Lang.tr("Queue #{0}", Integer.valueOf(booking.position())), + "SvInkChip")); + } + UserDto user = Session.user(); + if (booking.memberName() != null && user != null + && !booking.memberName().equals(user.displayName())) { + chips.add(Ui.label(Lang.tr("for {0}", first(booking.memberName())), + "SvInkChip")); + } + next.add(chips); + Button pass = new Button(Lang.tr("Show my pass"), "SvInkButton"); + pass.setName("next-pass"); + pass.addActionListener(e -> PassForm.show(form, null)); + Button more = new Button(Lang.tr("Details"), "SvInkText"); + more.getAllStyles().setBgTransparency(0); + more.setName("next-class"); + more.addActionListener(e -> BookingsForm.show(form)); + Container actions = new Container(new BorderLayout(), "SvInkRow"); + actions.add(BorderLayout.WEST, pass).add(BorderLayout.EAST, + more); + next.add(actions); + } + Ui.refresh(form); + } + + private void showStats(StatsDto found) { + if (!Nav.isHome(form) || found == null) { + return; + } + stats.removeAll(); + int span = Math.max(1, found.nextLevel() - found.levelFloor()); + Level ring = new Level(found.level(), Math.min(1.0, + (found.points() - found.levelFloor()) / (double) span)); + ring.setName("level"); + Container about = new Container(BoxLayout.y(), "SvLevelBox"); + // The ring already shows the level, so the words beside it lead with + // the points and say what the next level takes. + about.add(Ui.label(Lang.tr("{0} points", Integer.valueOf(found.points())), "SvHeading")); + about.add(Ui.label(found.nextLevel() > found.points() + ? Lang.tr("{0} more to level {1}", + Integer.valueOf(found.nextLevel() - found.points()), + Integer.valueOf(found.level() + 1)) + : Lang.tr("Top level"), "SvMuted")); + if (found.weekStreak() > 0) { + Label streak = Ui.label(Lang.tr("{0} week streak", + Integer.valueOf(found.weekStreak())), "SvBadgeGood"); + Ui.icon(streak, FontImage.MATERIAL_LOCAL_FIRE_DEPARTMENT, 2.6f); + // Flush with the words above it, not indented by the badge margin. + streak.getAllStyles().setMarginLeft(0); + about.add(FlowLayout.encloseIn(streak)); + } + Container levelRow = new Container(new BorderLayout()); + levelRow.add(BorderLayout.WEST, ring).add(BorderLayout.CENTER, about); + stats.add(levelRow); + + Container tiles = new Container(new com.codename1.ui.layouts.GridLayout(1, 3)); + tiles.add(stat(found.visitsThisMonth(), "visits", "visits")); + tiles.add(stat(found.minutesThisMonth(), "minutes", "minutes")); + tiles.add(stat(found.caloriesThisMonth(), "kcal", "calories")); + Ui.section(stats, "This month"); + stats.add(tiles); + List weekly = found.weeklyVisits(); + if (weekly != null && !weekly.isEmpty()) { + double[] values = new double[weekly.size()]; + for (int iter = 0; iter < values.length; iter++) { + Integer value = weekly.get(iter); + values[iter] = value == null ? 0 : value.intValue(); + } + stats.add(Ui.label(Lang.tr("Visits each week"), "SvChartTitle")); + stats.add(Chart.of("weekly", values, Lang.tr("{0} weeks ago", + Integer.valueOf(values.length)), Lang.tr("This week"))); + } + stats.add(Ui.label(Lang.tr("Badges {0}/{1}", Integer.valueOf(Badges.count(found.badges())), + Integer.valueOf(Badges.total())), "SvSection")); + stats.add(Badges.shelf(found.badges())); + if (!Badges.has(found.badges(), "STRAPPED_IN")) { + Container callout = new Container(new BorderLayout()); + callout.setUIID("SvCallout"); + callout.setName("strap-callout"); + Label icon = new Label("", "SvCalloutText"); + Ui.icon(icon, FontImage.MATERIAL_MONITOR_HEART, 5f); + callout.add(BorderLayout.WEST, icon); + callout.add(BorderLayout.CENTER, Ui.text( + "Wear a heart rate strap or your watch to class: +25 points a class " + + "and the Strapped in badge.", "SvCalloutText")); + Ui.tap(callout, "strap-callout", e -> WorkoutsForm.show(form)); + stats.add(callout); + } + Ui.refresh(form); + } + + private static Container stat(int value, String label, String name) { + Container tile = new Container(BoxLayout.y()); + tile.setUIID("SvTile"); + tile.setName("stat-" + name); + tile.add(new Label(String.valueOf(value), "SvTileValue")); + tile.add(new Label(Lang.tr(label), "SvTileLabel")); + return tile; + } + + private static Container tile(char icon, String text, String name, Runnable open) { + Button button = new Button(Lang.tr(text), "SvTileLabel"); + Ui.icon(button, icon, 4.4f); + button.setTextPosition(Label.BOTTOM); + button.getAllStyles().setAlignment(Label.CENTER); + button.setName(name); + button.addActionListener(e -> open.run()); + Container tile = new Container(new BorderLayout()); + tile.setUIID("SvTile"); + tile.add(BorderLayout.CENTER, button); + return tile; + } + + private static String greeting() { + int hour = java.time.LocalTime.now().getHour(); + return hour < 12 ? "Morning, {0}" : hour < 18 ? "Afternoon, {0}" + : "Evening, {0}"; + } + + private static String first(String name) { + int space = name == null ? -1 : name.indexOf(' '); + return space > 0 ? name.substring(0, space) : name == null ? "" : name; + } + + private static String until(long startsAt) { + long minutes = (startsAt - System.currentTimeMillis()) / 60000L; + if (minutes < 0) { + return Lang.tr("On now"); + } + if (minutes < 60) { + return Lang.tr("In {0} min", Long.valueOf(minutes)); + } + if (minutes < 24 * 60) { + return Lang.tr("In {0} h", Long.valueOf(minutes / 60)); + } + return Lang.tr("In {0} days", Long.valueOf(minutes / (24 * 60))); + } + + private void showPlan(List memberships) { + if (!Nav.isHome(form)) { + return; + } + plan.removeAll(); + plan.add(Ui.label("Membership", "SvHeading")); + if (memberships == null || memberships.isEmpty()) { + plan.add(Ui.text("No plan yet. Pick one to start booking classes.", "SvMuted")); + } + for (int iter = 0; memberships != null && iter < memberships.size(); iter++) { + MembershipDto membership = memberships.get(iter); + plan.add(Ui.row(FontImage.MATERIAL_CARD_MEMBERSHIP, membership.planName(), + first(membership.memberName()), Ui.badge(MembershipForm.state(membership.state()), + MembershipForm.badge(membership.state())))); + } + plan.add(Ui.link("Manage membership", "home-membership", + e -> MembershipForm.show(form))); + Ui.refresh(form); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MembershipForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MembershipForm.java new file mode 100644 index 00000000000..6142fb8281d --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/MembershipForm.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Sheet; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.FreezeDto; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.JoinDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.MembershipDto; +import com.codenameone.examples.pulse.api.PayInvoiceDto; +import com.codenameone.examples.pulse.api.PlanDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.pay.WalletForm; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The plan: who in the household is on which, and what can be done with it -- +/// freeze it for a while, cancel it at the end of the period, pay an invoice +/// that failed. And the plans on offer, to join. +/// +/// Money is the server's business. It prorates, retries a failed card on its +/// dunning ladder and decides when a membership is past due; this screen shows +/// what it says and sends what the member chose. Cards come from the wallet. +public final class MembershipForm { + private MembershipForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Membership", "Membership"); + Ui.back(form, previous); + final Container list = Ui.list("membership"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.membership().memberships(Net.to(memberships -> + Api.membership().invoices(Net.to(invoices -> fill(form, list, memberships, + invoices, load[0]), Ui.failed(list, load[0]))), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("See plans", "plans", + e -> plans(form, load[0])))); + form.show(); + load[0].run(); + } + + /// A membership state, in words. + public static String state(String state) { + if ("ACTIVE".equals(state)) { + return Lang.tr("Active"); + } + if ("FROZEN".equals(state)) { + return Lang.tr("Frozen"); + } + if ("PAST_DUE".equals(state)) { + return Lang.tr("Payment due"); + } + if ("CANCELLED".equals(state)) { + return Lang.tr("Cancelled"); + } + return state == null ? "" : state; + } + + /// The badge style for a membership state. + public static String badge(String state) { + return "ACTIVE".equals(state) ? "SvBadgeGood" + : "PAST_DUE".equals(state) || "CANCELLED".equals(state) ? "SvBadgeBad" + : "SvBadgeWarn"; + } + + private static void fill(final Form form, Container list, List memberships, + List invoices, final Runnable load) { + list.removeAll(); + Ui.section(list, "Plans"); + if (memberships == null || memberships.isEmpty()) { + list.add(Ui.text("Nobody in the household is on a plan yet.", "SvMuted")); + } + for (int iter = 0; memberships != null && iter < memberships.size(); iter++) { + final MembershipDto membership = memberships.get(iter); + String detail = membership.planName(); + if ("FROZEN".equals(membership.state())) { + detail += Ui.DOT + Lang.tr("frozen until {0}", Ui.day(membership.frozenUntil())); + } else if (membership.cancelAtPeriodEnd()) { + detail += Ui.DOT + Lang.tr("ends {0}", Ui.day(membership.currentPeriodEnd())); + } else if (membership.currentPeriodEnd() > 0) { + detail += Ui.DOT + Lang.tr("renews {0}", Ui.day(membership.currentPeriodEnd())); + } + if ("CLASS_PACK".equals(membership.kind())) { + detail += Ui.DOT + Lang.tr("{0} classes left", + Integer.valueOf(membership.creditsLeft())); + } + final int index = iter; + // Who it is for leads, since a household holds several of the + // same plan. Freezing and cancelling are rare, so they wait in a + // sheet behind a tap on the plan rather than under every row. + Container row = Ui.row(FontImage.MATERIAL_CARD_MEMBERSHIP, membership.memberName(), + detail, Ui.badge(state(membership.state()), badge(membership.state()))); + list.add(Ui.tap(row, "membership-" + iter, + e -> actions(form, membership, index, load))); + } + Ui.section(list, "Invoices"); + if (invoices == null || invoices.isEmpty()) { + list.add(Ui.text("No invoices yet.", "SvMuted")); + } + for (int iter = 0; invoices != null && iter < invoices.size(); iter++) { + final InvoiceDto invoice = invoices.get(iter); + boolean due = "OPEN".equals(invoice.status()) || "FAILED".equals(invoice.status()); + // The description already names the member; the line under it says + // where the money stands. + String detail = Ui.day(invoice.createdAt()) + Ui.DOT + (due ? Lang.tr("Due") + : "PAID".equals(invoice.status()) ? Lang.tr("Paid") : invoice.status()); + if (invoice.failure() != null && invoice.failure().length() > 0) { + detail += "\n" + invoice.failure(); + } + list.add(Ui.row(FontImage.MATERIAL_RECEIPT_LONG, invoice.description(), detail, + Ui.plain(Ui.money(invoice.amountCents() - invoice.creditCents(), + invoice.currency()), "SvRowAmount"))); + if (due) { + list.add(FlowLayout.encloseRight(Ui.link("Pay now", "pay-" + iter, + e -> WalletForm.pick(form, null, method -> + Api.membership().pay(invoice.id(), new PayInvoiceDto(method.id()), + Net.to(paid -> { + Telemetry.invoicePaid(); + Ui.say("Paid. Thank you"); + form.showBack(); + load.run(); + }, Ui::fail)))))); + } + } + Ui.section(list, "Time away"); + list.add(Ui.tap(Ui.row(FontImage.MATERIAL_BEACH_ACCESS, Lang.tr("Away periods"), + Lang.tr("Travelling or injured? No nudges, and billing can pause"), null), + "away", e -> AwayForm.show(form))); + Ui.refresh(form); + } + + private static void actions(final Form form, final MembershipDto membership, int iter, + final Runnable load) { + final Sheet sheet = new Sheet(null, membership.memberName()); + sheet.getContentPane().setLayout(BoxLayout.y()); + if ("FROZEN".equals(membership.state())) { + sheet.getContentPane().add(Ui.link("Unfreeze", "unfreeze-" + iter, e -> { + sheet.back(); + Api.membership().unfreeze(membership.id(), Net.to(done -> load.run(), Ui::fail)); + })); + } else if ("ACTIVE".equals(membership.state())) { + sheet.getContentPane().add(Ui.link("Freeze", "freeze-" + iter, e -> { + sheet.back(); + freeze(form, membership, load); + })); + } + if (!membership.cancelAtPeriodEnd() && !"CANCELLED".equals(membership.state())) { + sheet.getContentPane().add(Ui.link("Cancel at the end of this period", + "cancelPlan-" + iter, e -> { + sheet.back(); + Api.membership().cancel(membership.id(), Net.to(done -> { + Ui.say("Your plan ends at the end of this period"); + load.run(); + }, Ui::fail)); + })); + } + sheet.show(); + } + + private static void freeze(final Form previous, final MembershipDto membership, + final Runnable done) { + final Form form = Ui.form("Freeze", "Freeze"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text("A frozen plan is not billed and its classes cannot be booked. " + + "It starts again by itself on the last day.", "SvMuted")); + final TextField from = Ui.field(page, "From", "from", "YYYY-MM-DD"); + final TextField until = Ui.field(page, "Until", "until", "YYYY-MM-DD"); + page.add(Ui.primary("Freeze", "submit", e -> Api.membership().freeze(membership.id(), + new FreezeDto(from.getText().trim(), until.getText().trim()), Net.to(frozen -> { + previous.showBack(); + done.run(); + }, Ui::fail)))); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static void plans(final Form previous, final Runnable joined) { + final Form form = Ui.form("Plans", "Plans"); + Ui.back(form, previous); + final Container list = Ui.list("plans"); + form.add(BorderLayout.CENTER, list); + form.show(); + Api.household().members(Net.to(members -> Api.membership().plans(Net.to(plans -> { + list.removeAll(); + for (int iter = 0; plans != null && iter < plans.size(); iter++) { + final PlanDto plan = plans.get(iter); + if (!plan.active()) { + continue; + } + String detail = plan.description() == null ? "" : plan.description(); + if (plan.maxAge() > 0) { + detail += "\n" + Lang.tr("Ages {0} to {1}", Integer.valueOf(plan.minAge()), + Integer.valueOf(plan.maxAge())); + } + list.add(Ui.row(FontImage.MATERIAL_CARD_MEMBERSHIP, plan.name(), detail, + Ui.plain(Ui.money(plan.priceCents(), plan.currency()), "SvPrice"))); + Container buttons = new Container(new FlowLayout(com.codename1.ui.Component.RIGHT)); + for (int who = 0; members != null && who < members.size(); who++) { + final MemberDto member = members.get(who); + if (plan.maxAge() > 0 && member.age() > plan.maxAge() + || member.age() < plan.minAge()) { + continue; + } + buttons.add(Ui.link(Lang.tr("Join for {0}", member.name()), + "join-" + iter + "-" + who, e -> WalletForm.pick(form, null, + method -> Api.membership().join(new JoinDto(member.id(), + plan.id(), method.id()), Net.to(membership -> { + Telemetry.joined(plan.kind()); + Ui.say("Welcome to Pulse"); + previous.showBack(); + joined.run(); + }, Ui::fail))))); + } + list.add(buttons); + } + Ui.refresh(form); + }, Ui::fail)), Ui::fail)); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/NudgesForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/NudgesForm.java new file mode 100644 index 00000000000..dacb56f42e0 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/NudgesForm.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.NudgeDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Everything the studio sent: a place freed up in a queue, a streak about to +/// lapse, a challenge from a friend. The same messages arrive as push +/// notifications; this is where they stay. Opening one tells the server it was +/// read, which is how the owner's campaigns are measured. +public final class NudgesForm { + private NudgesForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Inbox", "Nudges"); + Ui.back(form, previous); + final Container list = Ui.list("nudges"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.engagement().nudges(Net.to(found -> fill(form, list, found, load[0]), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List nudges, + final Runnable load) { + list.removeAll(); + if (nudges == null || nudges.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_INBOX, Lang.tr("Nothing yet"), + Lang.tr("News from the studio shows up here."))); + } + for (int iter = 0; nudges != null && iter < nudges.size(); iter++) { + final NudgeDto nudge = nudges.get(iter); + boolean unread = nudge.openedAt() <= 0; + Container row = Ui.row(unread ? FontImage.MATERIAL_ADD_ALERT + : FontImage.MATERIAL_INBOX, nudge.title(), + nudge.body() + "\n" + Ui.when(nudge.sentAt()), + unread ? Ui.badge(Lang.tr("New"), "SvBadgeGood") : null); + list.add(Ui.tap(row, "nudge-" + iter, e -> { + if (nudge.openedAt() <= 0) { + Api.engagement().opened(nudge.id(), Net.to(opened -> load.run(), + (status, message) -> { })); + } + if (nudge.sessionId() != null && nudge.sessionId().length() > 0) { + ScheduleForm.show(form); + } + })); + } + Ui.refresh(form); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/PassForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/PassForm.java new file mode 100644 index 00000000000..af45eafcd52 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/PassForm.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.qrcode.QrCode; +import com.codename1.qrcode.QrCodeImage; +import com.codename1.security.Hash; +import com.codename1.security.Otp; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.PassDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ring; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The membership pass: a QR code the front desk scans, which changes every +/// period so a screenshot of it opens the door for half a minute at most. +/// +/// The phone and the server share a secret per member, and both work the code +/// out the same way: `com.codename1.security.Otp.hotp` over the number of +/// periods since the epoch, SHA-256, as many digits as the pass says (the +/// server's `studio/Passes.code`). The phone needs no network to show a fresh +/// one; it fetched the secret once. The QR code carries `pulse::`, +/// which is all the desk sends back. +/// +/// The pass also shows the member's NFC id, the one a key fob at the door is +/// written with. Rotating the pass makes the server forget the old secret and +/// fob id, for a phone that was lost. +public final class PassForm { + /// What the QR code starts with, as the server's `Passes.PREFIX`. + public static final String PREFIX = "pulse:"; + + private final Form form = Ui.form("Membership pass", "Pass"); + private final Container body = Ui.page(); + private final Label qr = new Label("", "SvPassQr"); + private final Label digits = new Label("", "SvPassCode"); + private final Ring ring = new Ring(); + private PassDto pass; + private byte[] secret; + private long shownCounter = -1; + private UITimer timer; + + private PassForm() { + } + + /// @param memberId whose pass, or null for the caller's own + public static void show(final Form previous, final String memberId) { + new PassForm().open(previous, memberId); + } + + private void open(final Form previous, final String memberId) { + Ui.back(form, () -> { + stop(); + previous.showBack(); + }); + qr.setName("pass-qr"); + digits.setName("pass-code"); + form.add(BorderLayout.CENTER, body); + body.add(new com.codenameone.examples.pulse.ui.Skeleton(3)); + form.show(); + if (memberId != null) { + load(memberId); + return; + } + Api.household().members(Net.to(members -> load(self(members)), Ui::fail)); + } + + private static String self(List members) { + for (int iter = 0; members != null && iter < members.size(); iter++) { + if ("SELF".equals(members.get(iter).relation())) { + return members.get(iter).id(); + } + } + return members == null || members.isEmpty() ? "" : members.get(0).id(); + } + + private void load(String memberId) { + Api.household().pass(memberId, Net.to(this::showPass, Ui::fail)); + } + + private void showPass(PassDto found) { + pass = found; + secret = bytes(found.secret()); + shownCounter = -1; + body.removeAll(); + // The pass is a card, like the one in a wallet: who it is for, the + // code on a white panel so any scanner reads it, and the digits for a + // desk whose scanner is down. + Container card = new Container(BoxLayout.y(), "SvInk"); + card.setName("pass-card"); + Container head = new Container(new BorderLayout(), "SvInkRow"); + head.add(BorderLayout.CENTER, Ui.label("PULSE MEMBER", "SvInkEyebrow")); + Label mark = new Label("", "SvWelcomeIcon"); + Ui.icon(mark, FontImage.MATERIAL_MONITOR_HEART, 4f); + head.add(BorderLayout.EAST, mark); + card.add(head); + card.add(Ui.label(found.memberName(), "SvInkTitle")); + Container panel = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE), "SvPassPanel"); + panel.add(BorderLayout.CENTER, qr); + card.add(panel); + Container code = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER), + "SvInkRow"); + code.add(BorderLayout.CENTER, digits); + code.add(BorderLayout.EAST, ring); + card.add(code); + body.add(card); + body.add(Ui.text(Lang.tr("Show this at the front desk. It refreshes every {0} seconds.", + Integer.valueOf(found.periodSeconds())), "SvMuted")); + body.add(Ui.row(FontImage.MATERIAL_NFC, Lang.tr("Key fob"), fob(found.nfcId()), null)); + body.add(Ui.link("Lost your phone or fob? Replace the pass", "rotate", + e -> Api.household().rotatePass(found.memberId(), Net.to(rotated -> { + Ui.say("Your old pass and fob no longer open the door"); + showPass(rotated); + }, Ui::fail)))); + tick(); + if (timer == null) { + timer = UITimer.timer(1000, true, form, this::tick); + } + Ui.refresh(form); + } + + private void tick() { + if (pass == null) { + return; + } + long now = System.currentTimeMillis(); + int period = pass.periodSeconds() <= 0 ? 30 : pass.periodSeconds(); + long counter = now / 1000L / period; + if (counter != shownCounter) { + shownCounter = counter; + String code = code(secret, counter, pass.digits()); + qr.setIcon(QrCodeImage.create(QrCode.encode(PREFIX + pass.memberId() + ":" + code, + QrCode.ECC_MEDIUM), Math.max(4, CN.convertToPixels(1.2f)))); + digits.setText(code.length() == 8 ? code.substring(0, 4) + " " + code.substring(4) + : code); + ring.start((int) (period - (now / 1000L) % period), period); + form.revalidate(); + } + } + + private void stop() { + if (timer != null) { + timer.cancel(); + timer = null; + } + } + + // The fob's id is long; its first eight characters are what the desk + // reads off a fob's label to match it. + private static String fob(String id) { + String label = id == null ? "" : id.length() > 8 ? id.substring(0, 8) : id; + // Upper case by hand: the id is hex, and String.toUpperCase is + // locale sensitive. + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < label.length(); iter++) { + char c = label.charAt(iter); + if (iter == 4) { + out.append(' '); + } + out.append(c >= 'a' && c <= 'z' ? (char) (c - 'a' + 'A') : c); + } + return out.toString(); + } + + /// The code a pass shows for `counter`, exactly as the server works it out. + public static String code(byte[] secret, long counter, int digits) { + return Otp.hotp(secret, counter, digits <= 0 ? 8 : digits, Hash.SHA256); + } + + /// Hex, as the server writes the secret, to bytes. + static byte[] bytes(String hex) { + if (hex == null) { + return new byte[0]; + } + byte[] out = new byte[hex.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) ((nibble(hex.charAt(iter * 2)) << 4) + | nibble(hex.charAt(iter * 2 + 1))); + } + return out; + } + + private static int nibble(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return 0; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Reminders.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Reminders.java new file mode 100644 index 00000000000..1bb701ade5d --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/Reminders.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.io.Preferences; +import com.codename1.notifications.LocalNotification; +import com.codename1.ui.Display; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Lang; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +/// A local notification an hour before every class the household holds a +/// place in. +/// +/// Local, not pushed: the phone knows the bookings, and a reminder that the +/// phone schedules itself still arrives when the network does not. They are +/// brought in line with the server's list whenever the app comes to the +/// front and whenever a booking is made or cancelled from this phone; a +/// booking made elsewhere is picked up the next time the app is opened. +/// +/// The ids scheduled are remembered, so a cancelled booking's reminder is +/// taken back, and so is every reminder when the account signs out. +public final class Reminders { + private static final String SCHEDULED = "pulse.reminders"; + private static final String PREFIX = "class-"; + private static final long BEFORE_MILLIS = 60L * 60L * 1000L; + + private Reminders() { + } + + /// The app came to the front: the reminders are checked against the + /// server's bookings. + public static void resumed() { + if (Session.user() != null) { + refresh(); + } + } + + /// Reads the bookings and schedules from them. + public static void refresh() { + Api.bookings().upcoming(Net.to(Reminders::sync, (status, message) -> { })); + } + + /// Schedules a reminder for every booked place in `bookings` and takes + /// back the ones no longer there. + public static void sync(List bookings) { + clear(); + if (!Prefs.notifyClasses() || bookings == null) { + return; + } + long now = System.currentTimeMillis(); + StringBuilder ids = new StringBuilder(); + for (BookingDto booking : bookings) { + if (!Classes.BOOKED.equals(booking.state()) || booking.session() == null) { + continue; + } + long at = booking.session().startsAt() - BEFORE_MILLIS; + if (at <= now || quiet(at)) { + continue; + } + LocalNotification note = new LocalNotification(); + note.setId(PREFIX + booking.id()); + note.setAlertTitle(booking.session().name()); + note.setAlertBody(Lang.tr("{0} starts in an hour. See you there, {1}", + booking.session().name(), Classes.nullToEmpty(booking.memberName()))); + Display.getInstance().scheduleLocalNotification(note, at, + LocalNotification.REPEAT_NONE); + if (ids.length() > 0) { + ids.append(','); + } + ids.append(note.getId()); + } + Preferences.set(SCHEDULED, ids.toString()); + } + + /// Takes back every reminder this phone scheduled. + public static void clear() { + String ids = Preferences.get(SCHEDULED, ""); + int start = 0; + while (start < ids.length()) { + int end = ids.indexOf(',', start); + if (end < 0) { + end = ids.length(); + } + if (end > start) { + Display.getInstance().cancelLocalNotification(ids.substring(start, end)); + } + start = end + 1; + } + Preferences.set(SCHEDULED, ""); + } + + /// Whether `millis` falls in the quiet hours chosen in Settings, which may + /// run over midnight. + static boolean quiet(long millis) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(new Date(millis)); + int hour = calendar.get(Calendar.HOUR_OF_DAY); + int from = Prefs.quietFrom(); + int to = Prefs.quietTo(); + if (from == to) { + return false; + } + return from < to ? hour >= from && hour < to : hour >= from || hour < to; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/ScheduleForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/ScheduleForm.java new file mode 100644 index 00000000000..e93ee77866e --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/ScheduleForm.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Component; +import com.codename1.ui.Sheet; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Skeleton; +import com.codenameone.examples.pulse.ui.Ui; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +/// The week's timetable, a day at a time. +/// +/// One filter narrows it: who in the household it is for. Choosing a child +/// shows only the classes the child is old enough for, and books, queues and +/// cancels for the child; choosing an adult shows the adult classes. The +/// server still has the last word on every booking. +/// +/// Places left are kept up to date over the live channel while the screen is +/// open: a class that fills up changes its row without being asked for. +public final class ScheduleForm extends LiveChannel.Adapter { + private static final long DAY = 24L * 60L * 60L * 1000L; + + private final Form form = Ui.form("Timetable", "Schedule"); + private final Container list = Ui.list("classes"); + private final Container filters = new Container(BoxLayout.y()); + private List sessions = new ArrayList(); + private List bookings = new ArrayList(); + private List household = new ArrayList(); + private long weekStart; + private int day; + private String memberId; + + private ScheduleForm() { + } + + public static void show(final Form previous) { + new ScheduleForm().open(previous); + } + + private void open(final Form previous) { + Ui.back(form, () -> { + LiveChannel.listen(null); + previous.showBack(); + }); + Calendar today = Calendar.getInstance(); + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + weekStart = today.getTime().getTime(); + + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + top.add(days()); + top.add(filters); + Ui.pull(list, this::load); + form.add(BorderLayout.NORTH, top); + form.add(BorderLayout.CENTER, list); + form.show(); + LiveChannel.listen(this); + Api.household().members(Net.to(found -> { + household = found == null ? new ArrayList() : found; + for (MemberDto member : household) { + if ("SELF".equals(member.relation())) { + memberId = member.id(); + } + } + if (memberId == null && !household.isEmpty()) { + memberId = household.get(0).id(); + } + buildFilters(); + load(); + }, Ui.failed(list, this::load))); + } + + private Container days() { + String[] labels = new String[7]; + String[] values = new String[7]; + SimpleDateFormat name = new SimpleDateFormat("EEE d"); + for (int iter = 0; iter < 7; iter++) { + labels[iter] = iter == 0 ? Lang.tr("Today") + : name.format(new Date(weekStart + iter * DAY)); + values[iter] = String.valueOf(iter); + } + return Ui.pills("day", labels, values, "0", value -> { + day = Integer.parseInt(value); + fill(); + }); + } + + private void buildFilters() { + filters.removeAll(); + if (household.size() > 1) { + String[] labels = new String[household.size()]; + String[] values = new String[household.size()]; + for (int iter = 0; iter < labels.length; iter++) { + labels[iter] = firstName(household.get(iter).name()); + values[iter] = household.get(iter).id(); + } + filters.add(Ui.pills("for", labels, values, memberId, value -> { + memberId = value; + fill(); + })); + } + } + + private void load() { + Api.bookings().upcoming(Net.to(found -> { + bookings = found == null ? new ArrayList() : found; + Api.studio().classes("", weekStart, 7, Net.to(classes -> { + sessions = classes == null ? new ArrayList() : classes; + fill(); + }, Ui.failed(list, this::load))); + }, Ui.failed(list, this::load))); + } + + @Override + public void sessionChanged(String id, int booked, int waitlisted, String state) { + for (int iter = 0; iter < sessions.size(); iter++) { + if (sessions.get(iter).id().equals(id)) { + load(); + return; + } + } + } + + @Override + public void bookingChanged(String id, String state) { + load(); + } + + private static String firstName(String name) { + int space = name == null ? -1 : name.indexOf(' '); + return space > 0 ? name.substring(0, space) : name == null ? "" : name; + } + + private MemberDto member() { + for (MemberDto member : household) { + if (member.id().equals(memberId)) { + return member; + } + } + return null; + } + + private void fill() { + list.removeAll(); + long from = weekStart + day * DAY; + long to = from + DAY; + MemberDto member = member(); + int shown = 0; + for (final SessionDto session : sessions) { + if (session.startsAt() < from || session.startsAt() >= to + || session.endsAt() < System.currentTimeMillis() + || member != null && member.age() >= 18 + && !Classes.inBand(session, Classes.BAND_ADULTS) + || member != null && !Classes.fits(session, member.age())) { + continue; + } + list.add(row(session, member)); + shown++; + } + if (shown == 0) { + list.add(Ui.empty(FontImage.MATERIAL_EVENT, Lang.tr("No classes"), + member == null ? Lang.tr("Nothing on this day.") + : Lang.tr("Nothing on this day for {0}.", member.name()))); + } + if (list.getComponentCount() == 0) { + list.add(new Skeleton(5)); + } + Ui.refresh(form); + } + + private Container row(final SessionDto session, final MemberDto member) { + final BookingDto held = member == null ? null + : Classes.bookingOf(bookings, session.id(), member.id()); + Component action = null; + if (held != null) { + action = Classes.bookedPill(session, held, this::load); + } else if (!"CANCELLED".equals(session.state())) { + boolean full = session.booked() >= session.capacity(); + final Button book = new Button(full ? Lang.tr("Queue") : Lang.tr("Book"), + full ? "SvPillQueue" : "SvPillBook"); + book.setName("book-" + session.id()); + book.addActionListener(e -> { + book.setEnabled(false); + Classes.book(session, member, this::load, () -> book.setEnabled(true)); + }); + action = book; + } + return Classes.card(session, action); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/SocialForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/SocialForm.java new file mode 100644 index 00000000000..ba8f1c0b83c --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/SocialForm.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.api.UserDto; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Sheet; +import com.codename1.ui.Button; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.FeedItemDto; +import com.codenameone.examples.pulse.api.FriendDto; +import com.codenameone.examples.pulse.api.FriendRequestDto; +import com.codenameone.examples.pulse.api.InviteDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Friends at the studio: asking someone by email, answering those who asked, +/// what friends have been doing (with kudos for it), and inviting a friend to +/// a class one is going to. +public final class SocialForm { + /// The friendship states the server reports. + public static final String FRIENDS = "FRIENDS"; + public static final String REQUESTED = "REQUESTED"; + public static final String INCOMING = "INCOMING"; + private static final long DAY = 24L * 3600000L; + + private SocialForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Friends", "Social"); + Ui.back(form, previous); + final Container list = Ui.list("social"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.social().friends(Net.to(friends -> Api.social().feed(Net.to( + feed -> fill(form, list, friends, feed, load[0]), Ui.failed(list, load[0]))), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List friends, + List feed, final Runnable load) { + list.removeAll(); + Ui.section(list, "Friends"); + for (int iter = 0; friends != null && iter < friends.size(); iter++) { + list.add(friend(form, friends.get(iter), iter, load)); + } + if (friends == null || friends.isEmpty()) { + list.add(Ui.text("No friends yet. Training is better together.", "SvMuted")); + } + + Ui.section(list, "Feed"); + UserDto me = Session.user(); + for (int iter = 0; feed != null && iter < feed.size(); iter++) { + list.add(feedRow(feed.get(iter), iter, me, load)); + } + if (feed == null || feed.isEmpty()) { + list.add(Ui.text("Your friends' classes and workouts appear here.", "SvMuted")); + } + + Ui.section(list, "Add a friend"); + final TextField email = Ui.field(list, "Their email", "friendEmail", + "friend@example.com", TextArea.EMAILADDR); + list.add(Ui.secondary("Send request", "requestFriend", e -> { + String address = email.getText().trim(); + if (address.indexOf('@') < 1) { + Ui.say(Lang.tr("Enter their email address.")); + return; + } + Api.social().request(new FriendRequestDto(address), Net.to(sent -> { + Ui.say(Lang.tr("Request sent")); + load.run(); + }, Ui::fail)); + })); + Ui.refresh(form); + } + + // A friend: a tap offers what can be done with them, and a request that + // came in has its Accept right on the row. + private static Container friend(final Form form, final FriendDto friend, int iter, + final Runnable load) { + boolean incoming = INCOMING.equals(friend.state()); + String detail = incoming ? Lang.tr("Wants to be friends") + : REQUESTED.equals(friend.state()) ? Lang.tr("Asked, waiting for them") + : friend.weekStreak() > 0 ? Lang.tr("{0} week streak", + Integer.valueOf(friend.weekStreak())) : Lang.tr("Getting started"); + Component end = null; + if (incoming) { + Button accept = new Button(Lang.tr("Accept"), "SvPillBook"); + accept.setName("accept-" + iter); + accept.addActionListener(e -> Api.social().accept(friend.account(), + Net.to(done -> load.run(), Ui::fail))); + end = accept; + } else if (FRIENDS.equals(friend.state())) { + Button invite = new Button(Lang.tr("Invite"), "SvPillQueue"); + invite.setName("invite-" + iter); + invite.addActionListener(e -> invite(form, friend)); + end = invite; + } + Container row = new Container(new BorderLayout(), "SvRow"); + row.setName("friend-" + iter); + row.add(BorderLayout.WEST, Ui.avatar(friend.name())); + Container lines = new Container(BoxLayout.y(), "SvAvatarLines"); + lines.add(Ui.label(friend.name(), "SvRowTitle")); + lines.add(Ui.label(detail, "SvRowDetail")); + Container east = new Container(new FlowLayout(Component.RIGHT, Component.CENTER)); + if (end != null) { + east.add(end); + } + // The rarer actions are a tap on the friend, not a button of their + // own, which left too little room for the name. + row.add(BorderLayout.CENTER, Ui.tap(lines, "remove-" + iter, e -> { + final Sheet sheet = new Sheet(null, friend.name()); + sheet.getContentPane().setLayout(BoxLayout.y()); + sheet.getContentPane().add(Ui.link(incoming ? "Decline" : "Remove friend", + "confirm-remove-" + iter, ev -> { + sheet.back(); + Api.social().remove(friend.account(), Net.to(done -> load.run(), + Ui::fail)); + })); + sheet.show(); + })); + row.add(BorderLayout.EAST, east); + return row; + } + + // One thing a friend did. Kudos is a pill with the count on it: a tap + // gives kudos once, and after that it shows they were given. + private static Container feedRow(final FeedItemDto item, int iter, UserDto me, + final Runnable load) { + boolean mine = me != null && item.name() != null && item.name().equals(me.displayName()); + StringBuilder detail = new StringBuilder(Ui.when(item.at())); + if (item.minutes() > 0) { + detail.append(Ui.DOT).append(Lang.tr("{0} min", Integer.valueOf(item.minutes()))); + } + Container row = new Container(new BorderLayout(), "SvRow"); + row.setName("feed-" + iter); + row.add(BorderLayout.WEST, Ui.avatar(item.name())); + Container lines = new Container(BoxLayout.y(), "SvAvatarLines"); + lines.add(Ui.text(Lang.tr(mine ? "You did {1}" : "{0} did {1}", + first(item.name()), WorkoutsForm.title(item.title())), "SvRowTitle")); + // When on one line and the effort on the next, as on the workouts + // screen: a single line wrapped mid-way on a phone. + lines.add(Ui.plain(detail.toString(), "SvRowDetail")); + if (item.avgHr() > 0) { + lines.add(Ui.plain(Lang.tr("{0} bpm avg", Integer.valueOf(item.avgHr())), + "SvRowDetail")); + } + row.add(BorderLayout.CENTER, lines); + final Button kudos = new Button(String.valueOf(item.kudos()), + mine ? "SvKudosMine" : item.kudoed() ? "SvKudosGiven" : "SvKudos"); + Ui.icon(kudos, item.kudoed() ? FontImage.MATERIAL_FAVORITE + : FontImage.MATERIAL_FAVORITE_BORDER, 2.8f); + kudos.setName("kudos-" + iter); + kudos.setAccessibilityText(Lang.tr("{0} kudos", Integer.valueOf(item.kudos()))); + if (!item.kudoed() && !mine) { + kudos.addActionListener(e -> { + kudos.setEnabled(false); + Api.social().kudos(item.id(), Net.to(done -> load.run(), Ui::fail)); + }); + } else { + kudos.setEnabled(false); + } + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(kudos)); + return row; + } + + private static String first(String name) { + int space = name == null ? -1 : name.indexOf(' '); + return space > 0 ? name.substring(0, space) : name == null ? "" : name; + } + + /// Picks one of the classes the member is booked into and invites + /// `friend` to it. + private static void invite(final Form previous, final FriendDto friend) { + final Form form = Ui.form(Lang.tr("Invite {0}", friend.name()), "Invite"); + Ui.back(form, previous); + final Container list = Ui.list("inviteClasses"); + form.add(BorderLayout.CENTER, list); + form.show(); + Api.studio().classes("", System.currentTimeMillis(), 7, Net.to(classes -> { + list.removeAll(); + Ui.section(list, "Which class?"); + for (int iter = 0; classes != null && iter < classes.size(); iter++) { + final SessionDto session = classes.get(iter); + if (Classes.CANCELLED.equals(session.state()) + || session.startsAt() > System.currentTimeMillis() + 7 * DAY) { + continue; + } + boolean going = session.mine() != null && !session.mine().isEmpty(); + list.add(Ui.tap(Ui.row(FontImage.MATERIAL_FITNESS_CENTER, session.name(), + Classes.detail(session), going ? Ui.badge(Lang.tr("You are going"), + "SvBadgeGood") : null), "inviteTo-" + iter, + e -> Api.social().invite(new InviteDto(session.id(), friend.account()), + Net.to(sent -> { + Ui.say(Lang.tr("{0} is invited", friend.name())); + previous.showBack(); + }, Ui::fail)))); + } + Ui.refresh(form); + }, Ui.failed(list, () -> invite(previous, friend)))); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/WorkoutsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/WorkoutsForm.java new file mode 100644 index 00000000000..4fe63749de6 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/member/WorkoutsForm.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.member; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.ui.ZoneBar; +import com.codenameone.examples.pulse.ui.Beat; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.CN; +import com.codename1.ui.Button; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.WorkoutDto; +import com.codenameone.examples.pulse.health.Workouts; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// The member's workouts: classes recorded here and workouts brought in from +/// the phone's health store. During a booked class this is where recording +/// it starts, which also shares the heart rate with the trainer. +public final class WorkoutsForm { + private static boolean share = true; + + private WorkoutsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Workouts", "Workouts"); + Ui.back(form, previous); + final Container list = Ui.list("workouts"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.engagement().workouts(Net.to(workouts -> + Api.bookings().upcoming(Net.to(upcoming -> + fill(form, list, workouts, upcoming, load[0]), Ui.failed(list, load[0]))), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List workouts, + List upcoming, final Runnable load) { + list.removeAll(); + list.add(Ui.row((char) 0, Lang.tr("Share with friends"), null, + Ui.toggle("share", share, on -> share = on))); + List booked = new ArrayList(); + for (int iter = 0; upcoming != null && iter < upcoming.size(); iter++) { + if (Classes.BOOKED.equals(upcoming.get(iter).state()) + || Classes.CHECKED_IN.equals(upcoming.get(iter).state())) { + booked.add(upcoming.get(iter).session()); + } + } + SessionDto recording = Workouts.recording(); + final SessionDto now = recording != null ? recording + : Workouts.classNow(booked, System.currentTimeMillis()); + if (now != null) { + // The class on now, on the same ink card as the home's next class, + // with the heart rate big enough to read mid-set. + Container live = new Container(BoxLayout.y(), "SvInk"); + live.setName("live"); + Container head = new Container(new BorderLayout(), "SvInkRow"); + head.add(BorderLayout.CENTER, Ui.label(recording != null ? "RECORDING" : "ON NOW", + "SvInkEyebrow")); + Beat beat = new Beat("SvBeat", recording != null); + beat.setPreferredW(CN.convertToPixels(14)); + beat.setPreferredH(CN.convertToPixels(5)); + head.add(BorderLayout.EAST, beat); + live.add(head); + live.add(Ui.text(now.name(), "SvInkTitle")); + final Label rate = Ui.label(recording != null ? "--" : "", "SvInkBpm"); + rate.setName("bpm"); + live.add(rate); + live.add(Ui.text(recording != null + ? "Wear a strap or your watch and this fills in. Worn sensors earn a bonus." + : "Record it to log the workout and earn points for the class.", + "SvInkText")); + Button act; + if (recording == null) { + act = new Button(Lang.tr("Record this class"), "SvInkButton"); + act.setName("recordClass"); + act.addActionListener(e -> { + Workouts.startClass(now, share, new Workouts.Progress() { + @Override + public void beat(int bpm) { + rate.setText(bpm + " bpm"); + rate.repaint(); + } + + @Override + public void ended(WorkoutDto posted, String error) { + Ui.say(error != null ? error : posted != null && posted.points() > 0 + ? Lang.tr("Workout saved: +{0} points", + Integer.valueOf(posted.points())) + : Lang.tr("Workout saved")); + load.run(); + } + }); + load.run(); + }); + } else { + act = new Button(Lang.tr("End workout"), "SvInkButton"); + act.setName("endClass"); + act.addActionListener(e -> Workouts.stopClass()); + } + live.add(FlowLayout.encloseIn(act)); + list.add(live); + } + if (Workouts.storeAvailable()) { + list.add(Ui.secondary("Import from Health", "importHealth", + e -> Workouts.importRecent(share, count -> { + int posted = count.intValue(); + Ui.say(posted < 0 ? Lang.tr("Health data could not be read") + : Lang.tr("{0} workouts added", count)); + load.run(); + }))); + } + Ui.section(list, "Recent"); + list.add(Ui.text("Wear a watch or a strap to earn bonus points.", + "SvMuted")); + for (int iter = 0; workouts != null && iter < workouts.size(); iter++) { + list.add(row(workouts.get(iter), iter)); + } + if (workouts == null || workouts.isEmpty()) { + list.add(Ui.text("No workouts yet.", "SvMuted")); + } + Ui.refresh(form); + } + + private static Container row(WorkoutDto workout, int index) { + boolean strap = Workouts.SOURCE_STRAP.equals(workout.source()); + boolean watch = Workouts.SOURCE_WATCH.equals(workout.source()); + Container row = new Container(new BorderLayout(), "SvRow"); + row.setName("workout-" + index); + Label icon = new Label("", strap || watch ? "SvWorkoutWorn" : "SvRowIcon"); + Ui.icon(icon, strap ? FontImage.MATERIAL_MONITOR_HEART : watch ? FontImage.MATERIAL_WATCH + : FontImage.MATERIAL_DIRECTIONS_RUN, 4f); + icon.setAccessibilityText(strap ? Lang.tr("Measured by a strap") + : watch ? Lang.tr("Measured by a watch") : ""); + row.add(BorderLayout.WEST, icon); + Container lines = new Container(BoxLayout.y()); + lines.add(Ui.label(title(workout.activity()), "SvRowTitle")); + // When and how long on one line, the effort on the next: one line of + // all four wrapped mid-unit on a phone. + lines.add(Ui.plain(Ui.when(workout.startedAt()) + Ui.DOT + + Lang.tr("{0} min", Integer.valueOf(workout.minutes())), "SvRowDetail")); + StringBuilder effort = new StringBuilder(); + if (workout.avgHr() > 0) { + effort.append(Lang.tr("{0} bpm avg", Integer.valueOf(workout.avgHr()))); + } + if (workout.calories() > 0) { + effort.append(effort.length() > 0 ? Ui.DOT : "").append(workout.calories()) + .append(" kcal"); + } + if (effort.length() > 0) { + lines.add(Ui.plain(effort.toString(), "SvRowDetail")); + } + ZoneBar zones = new ZoneBar(workout.zones()); + if (zones.hasData()) { + lines.add(zones); + } + row.add(BorderLayout.CENTER, lines); + if (workout.points() > 0) { + Label points = Ui.label("+" + workout.points(), "SvPoints"); + points.setAccessibilityText(Lang.tr("{0} points", Integer.valueOf(workout.points()))); + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(points)); + } + return row; + } + + /// `HIGH_INTENSITY_INTERVAL_TRAINING` as "High intensity interval training"; + /// a name that is not shouted, "HIIT Burn", as it is. + static String title(String activity) { + if (activity == null || activity.length() == 0) { + return Lang.tr("Workout"); + } + for (int iter = 0; iter < activity.length(); iter++) { + char c = activity.charAt(iter); + if (c >= 'a' && c <= 'z') { + return activity; + } + } + String words = activity.replace('_', ' '); + StringBuilder out = new StringBuilder(words.length()); + out.append(words.charAt(0)); + for (int iter = 1; iter < words.length(); iter++) { + char c = words.charAt(iter); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c); + } + return out.toString(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Account.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Account.java new file mode 100644 index 00000000000..09beccbdf98 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Account.java @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.net; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.api.PreferencesDto; +import com.codenameone.examples.pulse.api.ProfileDto; + +/// Keeps what the user chose in Settings with their account. +/// +/// [Prefs] is still what every screen reads and writes, and still on the +/// device: that copy is what the app starts from before the server has +/// answered, and all there is for someone who is signed out. This class is the +/// other half. When someone signs in it fetches the account's values and puts +/// them into `Prefs`, so a phone they have never used looks like the one they +/// left; and from then on it is told of every change and sends it back. +/// +/// A change is not sent the moment it is made. A name typed into a field +/// changes with every letter, so changes are gathered for a moment and sent as +/// one request with everything in it. +/// +/// A change is not forgotten when its request fails, either. The device's +/// values stay marked as unsent, on the device, until the server has taken +/// them: they are sent again a little later, and an account load that finds +/// them unsent -- the app restarted before the network came back -- sends them +/// rather than overwriting them with what the server still holds. +public final class Account { + /// How long after the last change the values are sent. + private static final int SETTLE_MILLIS = 700; + /// How long after a request failed it is sent again. + private static final int RETRY_MILLIS = 30000; + /// The keys under which each half is marked unsent; the value is the + /// account it was not sent for. + private static final String UNSENT_PREFERENCES = "pulse.account.unsent.preferences"; + private static final String UNSENT_PROFILE = "pulse.account.unsent.profile"; + + /// True while the account's values are being put into `Prefs`, which + /// reports each of them as a change like any other. + private static boolean applying; + private static boolean preferencesChanged; + private static boolean profileChanged; + private static boolean waiting; + /// Whether each half of the account's values has arrived since it signed + /// in. Until one has, the device's copy of it is not the account's -- it + /// is the defaults, or what an earlier session left -- and is not sent. + private static boolean preferencesLoaded; + private static boolean profileLoaded; + /// The profile as the server last gave it: the name and the gender are + /// part of it, and are sent back as they were with the rest. + private static ProfileDto profile; + /// What a request carries beyond the settings, for a test to look at. + private static PreferencesDto sent; + /// How many requests of each half were sent. An answer clears the unsent + /// mark only if no later request of its half went out after it. + private static int preferencesSends; + private static int profileSends; + + private Account() { + } + + /// Starts listening to `Prefs`. Called once, when the app starts. + public static void start() { + Prefs.onChange(Account::changed); + } + + /// Fetches the account's settings and profile into `Prefs`, then runs + /// `done` -- also when the server did not answer, with what the device + /// had. Each half is asked for whether or not the other came, and one that + /// did not come is asked for again before anything of it is saved. + public static void load(final Runnable done) { + if (Session.user() == null) { + done.run(); + return; + } + profile = null; + preferencesLoaded = false; + profileLoaded = false; + // What another account left unsent is not this one's to send. + String owner = Session.owner(); + if (!isUnsent(UNSENT_PREFERENCES, owner)) { + Preferences.delete(UNSENT_PREFERENCES); + } + if (!isUnsent(UNSENT_PROFILE, owner)) { + Preferences.delete(UNSENT_PROFILE); + } + fetch(done); + } + + private static void fetch(final Runnable done) { + final String owner = Session.owner(); + // An answer from a session that has since ended still runs `done`. + // Signing out and in while the account loads is rare, and the worst + // of it is the home screen shown a moment before its settings, which + // the new session's own load applies when it ends. The data itself is + // only taken for the account signed in now, which is checked below. + final Runnable thenProfile = () -> { + if (profileLoaded || !signedInAs(owner)) { + done.run(); + return; + } + Api.account().profile(Net.to(described -> { + if (signedInAs(owner)) { + if (described != null && isUnsent(UNSENT_PROFILE, owner)) { + // The device has a change the server never took: + // the server's name and gender are kept, the rest is + // the device's, and is sent. + profile = described; + profileLoaded = true; + profileChanged = true; + schedule(SETTLE_MILLIS); + } else { + apply(described); + profileLoaded = described != null; + } + } + done.run(); + }, (status, message) -> done.run())); + }; + if (preferencesLoaded) { + thenProfile.run(); + return; + } + Api.account().preferences(Net.to(preferences -> { + if (signedInAs(owner)) { + if (preferences != null && isUnsent(UNSENT_PREFERENCES, owner)) { + // As for the profile: the device's are newer. + preferencesLoaded = true; + preferencesChanged = true; + schedule(SETTLE_MILLIS); + } else { + apply(preferences); + preferencesLoaded = preferences != null; + } + } + thenProfile.run(); + }, (status, message) -> thenProfile.run())); + } + + /// The profile last fetched or saved, or null before there is one. + public static ProfileDto profile() { + return profile; + } + + /// Saves the name, which only the profile screen changes. + public static void saveProfile(final String name, final Runnable done, + final Net.Failed failed) { + if (!profileLoaded) { + // The rest of the profile goes out with the name, and it has not + // been seen yet; see send(). + fetch(() -> { + if (profileLoaded) { + saveProfile(name, done, failed); + } else { + failed.failed(0, "Could not reach the server. Check your connection."); + } + }); + return; + } + ProfileDto wanted = new ProfileDto(name); + final String owner = Session.owner(); + Api.account().saveProfile(wanted, Net.to(user -> { + // The answer is about the account that asked; see send(). + if (signedInAs(owner)) { + profile = wanted; + Session.setUser(user); + } + done.run(); + }, failed)); + } + + /// The preferences sent last, or null when none were. + public static PreferencesDto lastSent() { + return sent; + } + + /// Sends what is waiting now, for a screen that is about to end the session. + public static void flush() { + waiting = false; + send(); + } + + private static void changed(String name) { + if (applying || Session.user() == null || Prefs.OWNER.equals(name)) { + return; + } + preferencesChanged = true; + schedule(SETTLE_MILLIS); + } + + /// Sends what has changed `millis` from now, unless a send is already + /// waiting. + private static void schedule(int millis) { + if (!waiting) { + waiting = true; + CN.setTimeout(millis, () -> CN.callSerially(() -> { + if (waiting) { + waiting = false; + send(); + } + })); + } + } + + private static void send() { + if (Session.user() == null) { + preferencesChanged = false; + profileChanged = false; + return; + } + if (preferencesChanged && !preferencesLoaded || profileChanged && !profileLoaded) { + // The account's values never arrived, so what the device holds in + // their place is not known to be this account's: sent, it would + // replace on the server what was never seen here. They are asked + // for again instead, and the change that brought this about gives + // way to them. A later change is saved as usual once they are in. + preferencesChanged = preferencesChanged && preferencesLoaded; + profileChanged = profileChanged && profileLoaded; + fetch(() -> { }); + } + final String owner = Session.owner(); + if (preferencesChanged) { + preferencesChanged = false; + sent = preferences(); + // Marked before it goes, and cleared once the server has it: until + // then the device's values are the account's newest. + Preferences.set(UNSENT_PREFERENCES, owner); + final int send = ++preferencesSends; + Api.account().savePreferences(sent, Net.to(saved -> { + if (send == preferencesSends) { + sentFor(UNSENT_PREFERENCES, owner); + } + }, (status, message) -> { + if (refused(status)) { + // Refused: sent again, it would be refused again. + if (send == preferencesSends) { + sentFor(UNSENT_PREFERENCES, owner); + } + } else if (signedInAs(owner)) { + preferencesChanged = true; + schedule(RETRY_MILLIS); + } + })); + } + if (profileChanged) { + profileChanged = false; + final ProfileDto wanted = described(); + Preferences.set(UNSENT_PROFILE, owner); + final int send = ++profileSends; + Api.account().saveProfile(wanted, Net.to(user -> { + if (send == profileSends) { + sentFor(UNSENT_PROFILE, owner); + } + // Signing out sends what is waiting and does not wait for the + // answer. If somebody else has signed in by the time it comes, + // this profile is not theirs: kept, its name would + // go out with their next change and overwrite their own. + if (signedInAs(owner)) { + profile = wanted; + } + }, (status, message) -> { + if (refused(status)) { + if (send == profileSends) { + sentFor(UNSENT_PROFILE, owner); + } + } else if (signedInAs(owner)) { + profileChanged = true; + schedule(RETRY_MILLIS); + } + })); + } + } + + /// Whether the server refused a request for what it carried, so that sent + /// again it would be refused again. Anything else -- the server not + /// reached (0), busy, failing, or the session needing a new token -- may + /// succeed later, and the change is kept for then. + private static boolean refused(int status) { + return status >= 400 && status < 500 && status != 401 && status != 408 + && status != 429; + } + + /// Whether `key` marks a change that `owner`'s account has not taken. + private static boolean isUnsent(String key, String owner) { + return owner != null && owner.equals(Preferences.get(key, "")); + } + + /// The server has what `owner` left unsent under `key`. The mark is one + /// key for every account, so an answer that comes after somebody else + /// signed in leaves theirs alone: it may be a change of their own that + /// never went through, and cleared it would be lost to the server's copy. + private static void sentFor(String key, String owner) { + if (isUnsent(key, owner)) { + Preferences.delete(key); + } + } + + /// Whether the account signed in now is `owner` ([Session#owner()]): an + /// answer that comes back after the session it was asked in has ended is + /// not acted on. + private static boolean signedInAs(String owner) { + return owner != null && owner.equals(Session.owner()); + } + + private static PreferencesDto preferences() { + String language = Prefs.language(); + return new PreferencesDto( + language.length() == 0 ? com.codenameone.examples.pulse.ui.Lang.current() + : language, + Prefs.theme(), + Prefs.units(), + Prefs.notifyClasses(), + Prefs.notifyReceipts(), + Prefs.notifyPromotions(), + Prefs.paymentMethod(), + Prefs.quietFrom(), + Prefs.quietTo()); + } + + /// The profile to send: the one the server gave, or the name signed in + /// with. + private static ProfileDto described() { + return new ProfileDto(profile == null ? Session.user().displayName() : profile.name()); + } + + private static void apply(PreferencesDto from) { + if (from == null) { + return; + } + applying = true; + try { + // An account that has never chosen a language leaves the device's. + if (from.language() != null && from.language().length() > 0) { + Prefs.setLanguage(from.language()); + } + Prefs.setTheme(from.theme()); + Prefs.setUnits(from.units()); + Prefs.setNotifyClasses(from.notifyClasses()); + Prefs.setNotifyReceipts(from.notifyReceipts()); + Prefs.setNotifyPromotions(from.notifyPromotions()); + String method = from.defaultPaymentMethodId(); + if (method != null && method.length() > 0) { + Prefs.setPaymentMethod(method); + } + Prefs.setQuiet(from.quietFrom(), from.quietTo()); + } finally { + applying = false; + } + } + + private static void apply(ProfileDto from) { + if (from != null) { + profile = from; + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Api.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Api.java new file mode 100644 index 00000000000..0f1f085cf15 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Api.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.net; + +import com.codename1.io.rest.RestClients; +import com.codenameone.examples.pulse.api.AccountApi; +import com.codenameone.examples.pulse.api.StudioApi; +import com.codenameone.examples.pulse.api.BookingApi; +import com.codenameone.examples.pulse.api.HouseholdApi; +import com.codenameone.examples.pulse.api.MembershipApi; +import com.codenameone.examples.pulse.api.PaymentApi; +import com.codenameone.examples.pulse.api.EngagementApi; +import com.codenameone.examples.pulse.api.SocialApi; +import com.codenameone.examples.pulse.api.DeskApi; +import com.codenameone.examples.pulse.api.TrainerApi; +import com.codenameone.examples.pulse.api.OwnerApi; +import com.codenameone.examples.pulse.api.AdminApi; + +/// The server, as the app calls it. +/// +/// Each of these is an implementation the build generated from an interface in +/// the `shared` module. The server implements the other half of the same +/// interface, so a path or a field that changes on one side stops the other +/// from compiling. +public final class Api { + private static AccountApi account; + private static StudioApi studio; + private static BookingApi bookings; + private static HouseholdApi household; + private static MembershipApi membership; + private static PaymentApi payments; + private static EngagementApi engagement; + private static SocialApi social; + private static DeskApi desk; + private static TrainerApi trainer; + private static OwnerApi owner; + private static AdminApi admin; + + private Api() { + } + + static void connect(String baseUrl) { + account = RestClients.create(AccountApi.class, baseUrl); + studio = RestClients.create(StudioApi.class, baseUrl); + bookings = RestClients.create(BookingApi.class, baseUrl); + household = RestClients.create(HouseholdApi.class, baseUrl); + membership = RestClients.create(MembershipApi.class, baseUrl); + payments = RestClients.create(PaymentApi.class, baseUrl); + engagement = RestClients.create(EngagementApi.class, baseUrl); + social = RestClients.create(SocialApi.class, baseUrl); + desk = RestClients.create(DeskApi.class, baseUrl); + trainer = RestClients.create(TrainerApi.class, baseUrl); + owner = RestClients.create(OwnerApi.class, baseUrl); + admin = RestClients.create(AdminApi.class, baseUrl); + } + + /// Registration, the account, its preferences and push devices. + public static AccountApi account() { + return account; + } + + /// Locations, class types, trainers and the timetable. + public static StudioApi studio() { + return studio; + } + + /// The household's places in classes, and the queues for them. + public static BookingApi bookings() { + return bookings; + } + + /// The caller and their children, their waivers and membership passes. + public static HouseholdApi household() { + return household; + } + + /// Plans, memberships, freezes and invoices. + public static MembershipApi membership() { + return membership; + } + + /// Saved cards and the server's payment config. + public static PaymentApi payments() { + return payments; + } + + /// Stats, away periods, nudges and workouts. + public static EngagementApi engagement() { + return engagement; + } + + /// Friends, invites and kudos. + public static SocialApi social() { + return social; + } + + /// The front desk: check-in, member lookup, walk-ins and today. + public static DeskApi desk() { + return desk; + } + + /// A trainer's classes, rosters and shifts. + public static TrainerApi trainer() { + return trainer; + } + + /// The owner's dashboard, plans, staff and campaigns. + public static OwnerApi owner() { + return owner; + } + + /// Moderation and account administration. + public static AdminApi admin() { + return admin; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Net.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Net.java new file mode 100644 index 00000000000..51c8587c13d --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Net.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.net; + +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import com.codenameone.examples.pulse.ui.Lang; + +/// Makes a call through a generated client and sorts out what came back. +/// +/// A generated method answers with a `Response` whatever happened: null when +/// the server could not be reached, and otherwise a status with either the +/// typed result or, for a refusal, the server's reason as text. This turns that +/// into one of two callbacks, so a screen writes what to do with the result and +/// what to do with a failure, and nothing else. +/// +/// ```java +/// Api.studio().session(id, Net.to(session -> show(session), Ui::fail)); +/// ``` +public final class Net { + /// A call to a generated client method, waiting for its callback. + public interface Call { + void run(OnComplete> callback); + } + + public interface Ok { + void got(T value); + } + + public interface Failed { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param message something to show the user + void failed(int status, String message); + } + + private Net() { + } + + /// The callback to hand a generated method: `ok` gets the result, `failed` + /// everything else. + public static OnComplete> to(final Ok ok, final Failed failed) { + return new OnComplete>() { + @Override + public void completed(Response response) { + if (response == null) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + int status = response.getResponseCode(); + if (status >= 200 && status < 300) { + ok.got(response.getResponseData()); + return; + } + failed.failed(status, reason(response, status)); + } + }; + } + + /// Makes a call that was handed over as a value, which is how one screen + /// serves several calls of the same shape. + public static void send(Call call, Ok ok, Failed failed) { + call.run(to(ok, failed)); + } + + /// What the server said when it refused. Read as an `Object` and tested, + /// never cast to the type the call was declared with: on a refusal the body + /// is text whatever that type is. + private static String reason(Response response, int status) { + Object body = response.getResponseData(); + if (body instanceof String && ((String) body).length() > 0 + && ((String) body).length() < 200 && ((String) body).indexOf('<') < 0 + && ((String) body).indexOf('{') < 0) { + return (String) body; + } + if (status == 401) { + return "Please sign in again."; + } + if (status == 403) { + return "Your account is not allowed to do that."; + } + if (status == 429) { + return "Too many attempts. Wait a moment and try again."; + } + return Lang.tr("Something went wrong ({0}). Try again.", String.valueOf(status)); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Session.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Session.java new file mode 100644 index 00000000000..df1a38542b6 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/net/Session.java @@ -0,0 +1,546 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcRequestAuthorizer; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.PkceChallenge; +import com.codename1.io.oidc.SecureStorageTokenStore; +import com.codename1.io.oidc.TokenStore; +import com.codename1.ui.CN; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.pulse.AppConfig; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.api.UserDto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; + +/// Who is signed in, and the tokens that say so. +/// +/// Signing in is OAuth's authorization-code flow with PKCE against the app's +/// own server, made without a browser: the app shows its own sign-in screen, +/// posts the password to the server for a short-lived session, exchanges the +/// session for an authorization code and the code for tokens. The password is +/// sent once, to the server that owns it, and is never stored; what is stored +/// is the token pair, in the device's secure storage. +/// +/// From then on [OidcRequestAuthorizer] does the work: it puts the access token +/// on every request under `/api`, renews it shortly before it expires, and +/// renews and retries when the server refuses one. When renewing fails -- the +/// refresh token ran out, or the account was suspended -- it says so, and the +/// app goes back to the welcome screen. +public final class Session { + private static final String STORE_PREFIX = "pulse"; + private static final String SESSION_COOKIE = "CN1SESSION"; + + private static String base; + private static OidcClient client; + private static OidcRequestAuthorizer authorizer; + private static TokenStore store; + private static UserDto user; + private static Runnable onSignedOut; + /// Counts the sessions: moved on whenever the server changes, an account + /// signs in, or the session ends. An answer to a request made in an + /// earlier session describes somebody who is no longer signed in here -- + /// another server's account, or the account before a sign-out on this + /// one -- and is dropped rather than installed for whoever is now. + private static int session; + + private Session() { + } + + /// Points the app at a server. Called at start-up, and again when the + /// server address is changed. + public static void start(String baseUrl) { + if (store == null) { + // Falls back to ordinary storage where the platform has no keystore + // to offer, which is the simulator. + store = new SecureStorageTokenStore().allowPlainStorageFallback(true); + } + if (base != null) { + NetworkManager.getInstance().setAuthorizer(base + "/api", null); + if (!base.equals(baseUrl)) { + // Changing the server ends the session with the old one, and + // its tokens go with it. + store.clear(storeKey(base)); + } + } + base = baseUrl; + setUser(null); + session++; + client = OidcClient.create(OidcConfiguration.newBuilder() + .issuer(base) + .authorizationEndpoint(base + "/oauth2/authorize") + .tokenEndpoint(base + "/oauth2/token") + .revocationEndpoint(base + "/oauth2/revoke") + .jwksUri(base + "/oauth2/jwks") + .build()) + .setClientId(AppConfig.CLIENT_ID) + .setRedirectUri(AppConfig.redirectUri()) + .setScopes("openid", "profile") + .setTokenStore(store) + .setStoreKey(storeKey(base)); + authorizer = new OidcRequestAuthorizer(client); + authorizer.install(base + "/api"); + // Not tied to this start(). A request to an earlier server still in + // flight when the app switched, answered 401 with its refresh refused, + // signs the new server out too: one more sign-in, after a server + // switch that already asked for one, and nothing of either account is + // exposed by it. + authorizer.addSignInRequiredListener((source, reason) -> { + setUser(null); + session++; + if (onSignedOut != null) { + onSignedOut.run(); + } + }); + Api.connect(base); + } + + /// Where the tokens issued by one server are kept: under a name made from + /// that server's address. + /// + /// One name for every server would hand the tokens of the last one to + /// whichever address is typed next -- the app would restore them and send + /// them, refresh token included, to a server that never issued them. + /// Every character that is not a letter or a digit is written as its code, + /// so two addresses never share a name and the name is one any platform's + /// storage accepts. + static String storeKey(String server) { + StringBuilder key = new StringBuilder(STORE_PREFIX).append('-'); + for (int iter = 0; iter < server.length(); iter++) { + char c = server.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + key.append(c); + } else { + key.append('_').append(Integer.toHexString(c)).append('_'); + } + } + return key.toString(); + } + + /// Keeps the tokens somewhere else. The tests keep them in memory, so one + /// run leaves nothing behind for the next. + public static void useStore(TokenStore tokens) { + store = tokens; + } + + /// What to do when the session ends without the user asking: go back to + /// the welcome screen. + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + /// The signed-in user as the server last described them, or null. + public static UserDto user() { + return user; + } + + public static void setUser(UserDto described) { + user = described; + if (described != null && described.username() != null) { + // What the device keeps about one person is not the next one's. + Prefs.ownedBy(owner(described.username())); + } + } + + /// Who what the device keeps belongs to: the signed-in account, or null. + /// See [#owner(String)]. + public static String owner() { + return user == null || user.username() == null ? null : owner(user.username()); + } + + /// The device's name for the account `username` on the server in use. A + /// name is unique on one server only: the same e-mail address signed in + /// to two servers is two accounts, and what one left -- recent places, + /// a change it has not sent yet -- is not the other's to see or send. + public static String owner(String username) { + return storeKey(base == null ? "" : base) + "/" + accountName(username); + } + + /// Picks up the session a previous run left, if it is still good. + /// + /// @param signedIn there is a session, and [#user] is set + /// @param signedOut there is none + /// @param unreachable there is one, but the server did not answer + public static void restore(final Runnable signedIn, final Runnable signedOut, + final Runnable unreachable) { + // Loading the tokens can outlast the session it was asked in -- the + // server is changed while it reads -- and then neither answer is this + // session's to act on: see [#session]. + final int asked = session; + authorizer.load().ready(new SuccessCallback() { + @Override + public void onSucess(OidcTokens tokens) { + if (asked != session) { + return; + } + if (tokens == null || tokens.getAccessToken() == null) { + signedOut.run(); + return; + } + refreshUser(signedIn, (status, message) -> { + if (status == 0) { + unreachable.run(); + } else { + signedOut.run(); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == session) { + signedOut.run(); + } + } + }); + } + + /// Asks the server who this is. The roles in the answer decide which modes + /// the app offers, so it is asked again whenever that could have changed. + public static void refreshUser(final Runnable done, Net.Failed failed) { + // An answer from a session that has since ended -- the server was + // changed while an approval poll waited, or the account signed out + // and another signed in -- describes that account, not this one; + // it is dropped. See [#session]. + final int asked = session; + Api.account().me(Net.to(described -> { + if (asked != session) { + return; + } + setUser(described); + done.run(); + }, (status, message) -> { + if (asked == session) { + failed.failed(status, message); + } + })); + } + + /// Signs in with an e-mail address and password. + /// An e-mail address the way the server names its account: trimmed, with + /// its ASCII letters in lower case (`Accounts.email` on the server). Signing + /// in as `Courier@Pulse.example` must reach, and record its mode under, the + /// same account as `courier@pulse.example`. Folded by hand: `toLowerCase()` + /// follows the device locale, and under Turkish the `I` would not fold to `i`. + public static String accountName(String email) { + if (email == null) { + return null; + } + char[] chars = email.trim().toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + public static void signIn(String email, String password, final Runnable done, + final Net.Failed failed) { + // The server can be changed while the sign-in is under way, and + // start() then puts a new authorizer in place. Tokens the old server + // issued are dropped rather than installed in the new one, which would + // send them -- the refresh token too -- to a server that never issued + // them. See [#session]. + final int asked = session; + authenticate(email, password, tokens -> { + if (asked != session) { + return; + } + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }, failed); + } + + /// Exchanges an e-mail address and password for tokens, and does nothing + /// with them: [#signIn] is this, and then keeping what comes back. + public static void authenticate(final String email, final String password, + final Net.Ok got, final Net.Failed failed) { + final PkceChallenge pkce = PkceChallenge.generate(); + // Any unguessable value does for the state; this is one. + final String state = PkceChallenge.generate().getVerifier(); + // In a browser the app sees neither the session cookie nor a redirect: + // the browser keeps the first and follows the second. So there the + // password's answer is its status, and the code is read from the page + // the redirect ends on -- see AppConfig.redirectUri(). + final boolean web = AppConfig.pageOrigin() != null; + final String redirect = AppConfig.redirectUri(); + + // Every step goes to the server the sign-in started with, and none goes + // anywhere once the user has changed server: the session cookie of + // step 1 and the verifier of step 3 are only for the first server, and + // a new one that received them could replay them there for tokens. + final String server = base; + // 1. The password, for a session. + final Step login = new Step("POST", server + "/login"); + login.addArgument("username", accountName(email)); + login.addArgument("password", password); + login.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + if (login.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + if (login.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + return; + } + if (web ? login.status != 200 : login.session == null) { + failed.failed(401, "That e-mail and password do not match an account."); + return; + } + // 2. The session, for an authorization code. + final Step authorize = new Step("GET", server + "/oauth2/authorize"); + if (web) { + // Or a session that did not take is answered with a page. + authorize.addRequestHeader("Accept", "application/json"); + } else { + authorize.addRequestHeader("Cookie", login.session); + } + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", redirect); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + String code = web ? authorize.answer("code") + : parameter(authorize.location, "code"); + String returned = web ? authorize.answer("state") + : parameter(authorize.location, "state"); + // The state has to come back as it went: a code that arrives + // with another was not issued for this sign-in. + if (code == null || !state.equals(returned)) { + failed.failed(authorize.status, "Signing in failed. Try again."); + return; + } + // 3. The code, for tokens. + final Step token = new Step("POST", server + "/oauth2/token"); + token.addArgument("grant_type", "authorization_code"); + token.addArgument("code", code); + token.addArgument("redirect_uri", redirect); + token.addArgument("code_verifier", pkce.getVerifier()); + token.addArgument("client_id", AppConfig.CLIENT_ID); + token.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + OidcTokens tokens = token.status == 200 ? token.tokens() : null; + if (tokens == null || tokens.getAccessToken() == null) { + failed.failed(token.status, "Signing in failed. Try again."); + return; + } + // 4. The session has done its work, and is ended rather + // than left to run out: in a browser it is a cookie that + // would go on signing this user in to whoever sat down + // next. Nothing waits on the answer; the tokens stand + // without it. + Step end = new Step("POST", server + "/logout"); + if (!web) { + end.addRequestHeader("Cookie", login.session); + } + end.send(() -> { }); + got.got(tokens); + }); + }); + }); + } + + /// Signs out: the refresh token is revoked at the server, so the session + /// is over there too and not only forgotten here. + public static void signOut(final Runnable done) { + OidcTokens tokens = authorizer.getTokens(); + if (tokens != null && tokens.getRefreshToken() != null) { + // Sent and not waited for. Signing out is something the user does + // to this phone, and it is done here whether or not the server + // answers: the tokens are gone from the device either way. A + // revocation that is lost leaves a refresh token only someone who + // already copied it off the phone could use, and it expires on + // its own; holding the sign-out until the network allows would + // trap the user signed in on a phone with no connection. + client.revoke(tokens.getRefreshToken()); + } + setUser(null); + session++; + authorizer.signOut().ready(new SuccessCallback() { + @Override + public void onSucess(Boolean cleared) { + done.run(); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + done.run(); + } + }); + } + + /// A parameter of the query string of `url`, or null. + static String parameter(String url, String name) { + int query = url == null ? -1 : url.indexOf('?'); + if (query < 0) { + return null; + } + int at = query + 1; + while (at < url.length()) { + int end = url.indexOf('&', at); + if (end < 0) { + end = url.length(); + } + if (url.startsWith(name + "=", at)) { + return Util.decode(url.substring(at + name.length() + 1, end), "UTF-8", false); + } + at = end + 1; + } + return null; + } + + /// One request of the sign-in. It follows no redirect and keeps no cookie + /// jar, because the redirect and the cookie are the answers it is after; + /// and it carries no bearer token, because there is not one yet. + private static final class Step extends ConnectionRequest { + int status; + String location; + String session; + private byte[] body; + private Runnable then; + private boolean reported; + + Step(String method, String url) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method)); + setFollowRedirects(false); + setCookiesEnabled(false); + setReadResponseForErrors(true); + setDuplicateSupported(true); + setFailSilently(true); + setAuthorizer(RequestAuthorizer.NONE); + } + + void send(Runnable next) { + then = next; + NetworkManager.getInstance().addToQueue(this); + } + + /// A text member of the JSON this request was answered with, or null. + String answer(String name) { + if (status != 200 || body == null) { + return null; + } + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body), "UTF-8")); + Object value = json == null ? null : json.get(name); + return value instanceof String ? (String) value : null; + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + OidcTokens tokens() { + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body == null ? new byte[0] : body), "UTF-8")); + return OidcTokens.fromTokenResponse(json, null); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + @Override + protected void readHeaders(Object connection) throws IOException { + location = getHeader(connection, "Location"); + String[] cookies = getHeaders(connection, "Set-Cookie"); + for (int iter = 0; cookies != null && iter < cookies.length; iter++) { + if (cookies[iter] != null && cookies[iter].startsWith(SESSION_COOKIE + "=")) { + int end = cookies[iter].indexOf(';'); + session = end < 0 ? cookies[iter] : cookies[iter].substring(0, end); + } + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + status = code; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (status == 0) { + status = getResponseCode(); + } + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (status == 0) { + status = getResponseCode(); + } + report(); + } + + @Override + protected void handleException(Exception err) { + status = 0; + report(); + } + + private void report() { + if (!reported) { + reported = true; + CN.callSerially(then); + } + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/notify/Notifications.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/notify/Notifications.java new file mode 100644 index 00000000000..6f080fc2e17 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/notify/Notifications.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.notify; + +import com.codename1.push.PushClient; +import com.codename1.push.PushError; +import com.codename1.push.PushListener; +import com.codename1.push.PushMessage; +import com.codename1.push.PushSubscription; +import com.codename1.ui.CN; +import com.codenameone.examples.pulse.AppConfig; +import com.codenameone.examples.pulse.api.PushDeviceDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Nav; + +/// Push notifications: the studio news that reaches a phone in a pocket. +/// +/// The phone registers with the platform's push service once, and the token +/// it gets is handed to the Pulse server for whoever is signed in. Signing +/// out takes it back, so the next person to use the phone is not told about +/// the last one's classes. The server decides what to send and when; see the +/// server's `Notifier`. +/// +/// Only on a phone (Android and iOS) and only with [AppConfig#PUSH_APP_KEY] +/// set. The simulator has no push service to register with, and a browser +/// build is told everything over the live channel while its page is open. +public final class Notifications { + private static final Net.Failed QUIET = (status, message) -> { }; + private static PushClient client; + private static PushSubscription subscription; + + private Notifications() { + } + + // tag::pulse-push-register[] + /// Asks the platform for a push token. Called every time the app comes to + /// the foreground; registering is idempotent, and the platform answers + /// with the token it already gave when nothing has changed. + public static void start() { + if (!available()) { + return; + } + if (client == null) { + client = PushClient.builder(AppConfig.PUSH_APP_KEY) + .listener(new Listener()) + .build(); + } + client.register(); + } + + /// Someone has signed in: the server learns where to reach them. + public static void signedIn() { + PushDeviceDto device = device(); + if (device != null && Session.user() != null) { + final String owner = Session.owner(); + Api.account().registerPush(device, Net.to(saved -> { + // Answered for an account that has since signed out, and + // perhaps after the next one's registration: sent again now, + // this one lands last and the phone is the current account's. + if (Session.user() != null && !owner.equals(Session.owner())) { + signedIn(); + } + }, (status, message) -> { + // A failure may be a lost answer to a registration that did + // commit, so it is reconciled the same way. + if (Session.user() != null && !owner.equals(Session.owner())) { + signedIn(); + } + })); + } + } + + /// Someone is signing out: the server forgets this phone for them, and + /// then `then` runs. It runs whether or not the server could be reached; + /// signing out never waits on a notification. + public static void signingOut(Runnable then) { + PushDeviceDto device = device(); + if (device == null || Session.user() == null) { + then.run(); + return; + } + // A failure is not kept for retrying. The row it would remove names + // this phone's token, and the next sign-in on the phone takes that row + // over for whoever signs in, as the server keeps a phone on the + // account that signed in on it last. What is lost to an outage is + // the window until then, on the phone of the account that signed out + // -- noted in the implementor's guide among the hardening a + // production deployment adds, not worth a durable queue in a sample. + Api.account().unregisterPush(device, Net.to(gone -> then.run(), + (status, message) -> then.run())); + } + // end::pulse-push-register[] + + /// Whether this build can be sent notifications at all. + public static boolean available() { + if (AppConfig.PUSH_APP_KEY.length() == 0 || CN.isSimulator()) { + return false; + } + String platform = CN.getPlatformName(); + return "and".equals(platform) || "ios".equals(platform); + } + + private static PushDeviceDto device() { + if (subscription == null) { + return null; + } + return new PushDeviceDto(subscription.getTransportId(), subscription.getToken(), + CN.getPlatformName()); + } + + // tag::pulse-push-listener[] + /// Called on the event thread. A message is a nudge -- a place freed up on + /// the waitlist, a streak about to lapse, a challenge: the app opens the + /// inbox, which reads the nudge from the server. The message's own words are + /// for the notification shade, not for the app. + private static final class Listener implements PushListener { + @Override + public void onRegistration(PushSubscription registered) { + subscription = registered; + signedIn(); + } + + @Override + public void onMessage(PushMessage message) { + Object kind = message.getData().get("kind"); + // A message with no title is a silent one, which the push client + // has already applied. + String title = message.getTitle(); + if (kind != null && title != null && title.length() > 0 + && Session.user() != null) { + Nav.showNudges(); + } + } + + @Override + public void onError(PushError error) { + // Nothing to tell the user: the app works without notifications, + // and asks again the next time it comes to the foreground. + System.out.println("Push registration failed: " + error.getCode() + " " + + error.getMessage()); + } + } + // end::pulse-push-listener[] +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/AccountsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/AccountsForm.java new file mode 100644 index 00000000000..26d7f7b6aac --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/AccountsForm.java @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.components.Switch; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.FlagDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.ModerationEventDto; +import com.codenameone.examples.pulse.api.RoleChangeDto; +import com.codenameone.examples.pulse.api.SuspendDto; +import com.codenameone.examples.pulse.api.UserDetailDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Layouts; +import com.codenameone.examples.pulse.ui.Skeleton; +import com.codenameone.examples.pulse.ui.Split; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The studio's accounts, for the owner: everyone who can sign in, the roles +/// each holds, and the two things an owner does about an account that worries +/// them -- flag it for a closer look, or block it. Every one of those is kept, +/// with who did it and why, and shown on the account. +public final class AccountsForm { + private static final String[] FILTERS = {"ALL", "STAFF", "FLAGGED", "BLOCKED"}; + private static final String[] FILTER_NAMES = {"All", "Staff", "Flagged", "Blocked"}; + + private final Form form = Ui.form("Accounts", "OwnerAccounts"); + private final Container list = Ui.list("accounts"); + private final TextField search = new TextField("", Lang.tr("Search by name or e-mail"), 20, + TextField.ANY); + private String filter = "ALL"; + private List users; + /// The list and the account picked side by side, on a wide window; null on a phone. + private Split split; + /// The account open beside the list, kept across a reload so that what was + /// done to it shows without losing the place. + private String opened; + + private AccountsForm() { + } + + public static void show(Form previous) { + new AccountsForm().open(previous); + } + + private void open(Form previous) { + Ui.back(form, previous); + search.setUIID("SvSearchField"); + search.getHintLabel().setUIID("SvFieldHint"); + search.setName("accountSearch"); + search.addDataChangedListener((type, index) -> fill()); + String[] names = new String[FILTERS.length]; + for (int iter = 0; iter < names.length; iter++) { + names[iter] = Lang.tr(FILTER_NAMES[iter]); + } + Container top = new Container(BoxLayout.y(), "SvSearchBar"); + top.add(search); + // The list is small enough to filter here: one request, and the chips + // answer at once. + top.add(Ui.pills("accountFilter", names, FILTERS, filter, value -> { + filter = value; + fill(); + })); + if (Layouts.wide()) { + split = new Split(form, top, list); + } else { + form.add(BorderLayout.NORTH, top); + form.add(BorderLayout.CENTER, list); + } + list.add(new Skeleton(6)); + Ui.pull(list, this::load); + // Back from an account, whatever was done to it shows in the list. + form.addShowListener(e -> load()); + form.show(); + CN.callSerially(() -> form.setFocused(null)); + } + + private void load() { + Api.admin().users(Net.to(found -> { + users = found; + fill(); + }, Ui.failed(list, this::load))); + } + + private void fill() { + if (users == null) { + return; + } + list.removeAll(); + if (split != null) { + split.clear(); + } + String wanted = search.getText().trim(); + int shown = 0; + Container first = null; + String firstName = null; + for (int iter = 0; iter < users.size(); iter++) { + final UserDto user = users.get(iter); + if (!matches(user, filter) || !MembersForm.contains(user.displayName(), wanted) + && !MembersForm.contains(user.username(), wanted)) { + continue; + } + final Container row = MembersForm.person(name(user), user.username(), state(user)); + list.add(Ui.tap(row, "accountRow" + shown, e -> open(row, user.username()))); + if (split != null && user.username().equals(opened)) { + split.pick(row); + } + if (first == null) { + first = row; + firstName = user.username(); + } + shown++; + } + // Beside the list there is always an account: the one that was open + // while it is still listed, or the first. + if (split != null && !split.hasPick() && first != null) { + open(first, firstName); + } + if (shown == 0) { + list.add(Ui.empty(FontImage.MATERIAL_MANAGE_ACCOUNTS, Lang.tr("No accounts here"), + Lang.tr("Try another filter."))); + } + Ui.refresh(form); + } + + static boolean matches(UserDto user, String filter) { + if ("STAFF".equals(filter)) { + return user.trainer() || user.desk() || user.owner(); + } + if ("FLAGGED".equals(filter)) { + return user.flagged(); + } + if ("BLOCKED".equals(filter)) { + return user.suspended(); + } + return true; + } + + private static String name(UserDto user) { + return user.displayName() == null || user.displayName().length() == 0 + ? user.username() : user.displayName(); + } + + /// Blocked outranks flagged, and either outranks a role. + static Label state(UserDto user) { + if (user.suspended()) { + return Ui.badge(Lang.tr("Blocked"), "SvBadgeBad"); + } + if (user.flagged()) { + return Ui.badge(Lang.tr("Flagged"), "SvBadgeWarn"); + } + // Staff by what they do; the highest role says the most. + if (user.owner()) { + return Ui.badge(Lang.tr("Owner"), "SvBadgeGood"); + } + if (user.trainer()) { + return Ui.badge(Lang.tr("Trainer"), "SvBadgeGood"); + } + if (user.desk()) { + return Ui.badge(Lang.tr("Front desk"), "SvBadgeGood"); + } + return null; + } + + // ---------------------------------------------------------------- account + + /// One account in full: who it is, what it has done here, its roles, and + /// what owners have done to it. + static void showAccount(final Form previous, final String username) { + final Form form = Ui.form("Account", "OwnerAccount"); + Ui.back(form, previous); + final Container page = Ui.page(); + form.add(BorderLayout.CENTER, page); + form.show(); + into(form, page, username); + } + + /// Opens the account `username`: beside the list on a wide window, on a + /// screen of its own on a phone. + private void open(Container row, String username) { + if (split == null) { + showAccount(form, username); + return; + } + opened = username; + split.pick(row); + into(form, split.detail(), username); + } + + /// Draws the account `username` into `page`, which is on `form`. + private static void into(final Form form, final Container page, final String username) { + page.setName("account"); + page.removeAll(); + page.add(new Skeleton(6)); + page.revalidate(); + page.putClientProperty("showing", username); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.admin().user(username, Net.to(found -> { + // Another account picked while this one was on its way wins. + if (username.equals(page.getClientProperty("showing"))) { + fillAccount(form, page, found, load[0]); + } + }, Ui.failed(page, load[0]))); + Ui.pull(page, load[0]); + load[0].run(); + } + + private static void fillAccount(final Form form, Container page, UserDetailDto detail, + final Runnable load) { + page.removeAll(); + final UserDto user = detail.user(); + Container head = new Container(new BorderLayout(), "SvPanel"); + head.setName("accountHeader"); + head.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(Ui.avatar(name(user)))); + Container names = new Container(BoxLayout.y()); + names.add(Ui.plain(name(user), "SvHeading")); + names.add(Ui.plain(user.username(), "SvMuted")); + if (user.phone() != null && user.phone().length() > 0) { + names.add(Ui.plain(user.phone() + (user.phoneVerified() ? "" : Ui.DOT + + Lang.tr("not verified")), "SvMuted")); + } + // Under the name rather than beside it, where it cut the address short. + Label state = state(user); + if (state != null) { + state.getAllStyles().setMarginLeft(0); + names.add(FlowLayout.encloseIn(state)); + } + head.add(BorderLayout.CENTER, names); + page.add(head); + if (user.flagged() && user.flagReason() != null && user.flagReason().length() > 0) { + page.add(Ui.callout(FontImage.MATERIAL_FLAG, Lang.tr("Flagged"), user.flagReason())); + } + + StatRow tiles = Ui.stats(3); + tiles.stat(String.valueOf(detail.visits()), "Visits", "accountVisits") + .stat(String.valueOf(detail.noShows()), "No-shows", "accountNoShows") + .stat(Ui.moneyShort(detail.spent(), null), "Spent", "accountSpent"); + page.add(tiles); + + // Roles: an owner cannot take their own owner role away, which would + // leave this screen with nobody able to give it back. + Ui.section(page, "Roles"); + boolean self = Session.user() != null + && user.username().equals(Session.user().username()); + final boolean[] roles = {user.trainer(), user.desk(), user.owner()}; + String[] titles = {"Trainer", "Front desk", "Owner"}; + String[] about = {"Teaches classes, sees rosters and the live view.", + "Checks people in at the door and takes walk-ins.", + "Runs the studio: everything on these screens."}; + for (int iter = 0; iter < roles.length; iter++) { + final int role = iter; + Switch toggle = Ui.toggle("accountRole-" + iter, roles[iter], on -> { + roles[role] = on; + Api.admin().setRoles(user.username(), new RoleChangeDto(roles[0], roles[1], + roles[2]), Net.to(saved -> load.run(), (status, message) -> { + Ui.fail(status, message); + load.run(); + })); + }); + toggle.setEnabled(!(self && iter == 2)); + Container row = Ui.row((char) 0, Lang.tr(titles[iter]), Lang.tr(about[iter]), + toggle); + page.add(row); + } + + List household = detail.household(); + if (household != null && household.size() > 1) { + Ui.section(page, "Household"); + for (int iter = 0; iter < household.size(); iter++) { + MemberDto member = household.get(iter); + String who = "SELF".equals(member.relation()) ? Lang.tr("Account holder") + : Lang.tr("Child") + Ui.DOT + Lang.tr("Age {0}", + Integer.valueOf(member.age())); + page.add(MembersForm.person(member.name(), who, member.waiverSigned() ? null + : Ui.badge(Lang.tr("No waiver"), "SvBadgeWarn"))); + } + } + + Ui.section(page, "History"); + List events = detail.events(); + for (int iter = 0; events != null && iter < events.size(); iter++) { + ModerationEventDto event = events.get(iter); + String reason = event.reason() == null || event.reason().length() == 0 ? "" + : Ui.DOT + event.reason(); + page.add(Ui.row(icon(event.action()), Lang.tr(verb(event.action())), + Ui.when(event.at()) + Ui.DOT + event.by() + reason, null)); + } + if (events == null || events.isEmpty()) { + page.add(Ui.text("Nothing done to this account yet.", "SvMuted")); + } + + if (!self) { + page.add(user.flagged() + ? Ui.secondary("Clear the flag", "unflag", e -> flag(user, false, "", load)) + : Ui.secondary("Flag for a closer look", "flag", e -> Ui.reason(form, + Lang.tr("Flag account"), Lang.tr("Other owners see the flag and why. The " + + "account keeps working."), Lang.tr("Flag"), why -> { + form.showBack(); + flag(user, true, why, load); + }))); + page.add(user.suspended() + ? Ui.secondary("Lift the block", "unblock", e -> block(user, false, "", load)) + : Ui.danger("Block account", "block", e -> Ui.reason(form, + Lang.tr("Block account"), Lang.tr("They are signed out at once and cannot " + + "sign in, book or check in until the block is lifted."), Lang.tr("Block"), + why -> { + form.showBack(); + block(user, true, why, load); + }))); + } + Ui.refresh(form); + } + + private static void flag(UserDto user, boolean on, String why, final Runnable load) { + Api.admin().flag(user.username(), new FlagDto(on, why), Net.to(saved -> load.run(), + Ui::fail)); + } + + private static void block(UserDto user, boolean on, String why, final Runnable load) { + Api.admin().suspend(user.username(), new SuspendDto(on, why), Net.to(saved -> load.run(), + Ui::fail)); + } + + private static char icon(String action) { + if ("block".equals(action)) { + return FontImage.MATERIAL_BLOCK; + } + if ("unblock".equals(action)) { + return FontImage.MATERIAL_LOCK_OPEN; + } + if ("flag".equals(action) || "unflag".equals(action)) { + return FontImage.MATERIAL_FLAG; + } + if ("roles".equals(action)) { + return FontImage.MATERIAL_BADGE; + } + return FontImage.MATERIAL_HISTORY; + } + + private static String verb(String action) { + if ("block".equals(action)) { + return "Blocked"; + } + if ("unblock".equals(action)) { + return "Block lifted"; + } + if ("flag".equals(action)) { + return "Flagged"; + } + if ("unflag".equals(action)) { + return "Flag cleared"; + } + if ("roles".equals(action)) { + return "Roles changed"; + } + if ("approve".equals(action)) { + return "Approved"; + } + if ("reject".equals(action)) { + return "Rejected"; + } + return action; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/BillingForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/BillingForm.java new file mode 100644 index 00000000000..31833dd455b --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/BillingForm.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.ReasonDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Every bill the studio has sent: what came in over the last 30 days, what +/// is still owed and which cards failed. A bill nobody should pay -- an open +/// one, or a failed one being retried -- can be voided, with a reason. +public final class BillingForm { + private static final String[] STATUSES = {"ALL", "PAID", "FAILED", "OPEN", "VOID"}; + private static final String[] STATUS_NAMES = {"All", "Paid", "Failed", "Open", "Void"}; + private static final long MONTH = 30L * 86400000L; + + private final Form form = Ui.form("Billing", "OwnerBilling"); + private final StatRow summary = Ui.stats(3); + private final Container list = Ui.list("invoices"); + private String status = "ALL"; + private List invoices; + + private BillingForm() { + } + + public static void show(Form previous) { + new BillingForm().open(previous); + } + + private void open(Form previous) { + Ui.back(form, previous); + String[] names = new String[STATUSES.length]; + for (int iter = 0; iter < names.length; iter++) { + names[iter] = Lang.tr(STATUS_NAMES[iter]); + } + Container top = new Container(BoxLayout.y(), "SvSearchBar"); + top.add(summary); + top.add(Ui.pills("status", names, STATUSES, status, value -> { + status = value; + fill(); + })); + form.add(BorderLayout.NORTH, top); + form.add(BorderLayout.CENTER, list); + Ui.pull(list, this::load); + form.show(); + load(); + } + + // Every invoice at once: the summary is of all of them, and the chips + // only narrow what is listed. + private void load() { + Api.owner().invoices("", Net.to(found -> { + invoices = found; + fill(); + }, Ui.failed(list, this::load))); + } + + private void fill() { + if (invoices == null) { + return; + } + long since = System.currentTimeMillis() - MONTH; + long collected = 0; + long outstanding = 0; + int failed = 0; + for (int iter = 0; iter < invoices.size(); iter++) { + InvoiceDto invoice = invoices.get(iter); + if ("PAID".equals(invoice.status()) && invoice.paidAt() >= since) { + collected += invoice.amountCents(); + } else if ("OPEN".equals(invoice.status()) || "FAILED".equals(invoice.status())) { + outstanding += invoice.amountCents(); + } + if ("FAILED".equals(invoice.status())) { + failed++; + } + } + summary.clear(); + summary.stat(Ui.moneyShort(collected, null), "Collected in 30 days", "collected") + .stat(Ui.moneyShort(outstanding, null), "Outstanding", "outstanding") + .stat(String.valueOf(failed), "Failed", "failedCount"); + list.removeAll(); + int shown = 0; + for (int iter = 0; iter < invoices.size(); iter++) { + InvoiceDto invoice = invoices.get(iter); + if ("ALL".equals(status) || status.equals(invoice.status())) { + list.add(row(form, invoice, "invoice-" + shown, this::load)); + shown++; + } + } + if (shown == 0) { + list.add(Ui.empty(FontImage.MATERIAL_RECEIPT_LONG, Lang.tr("No invoices"), + Lang.tr("Try another filter."))); + } + Ui.refresh(form); + } + + /// One bill: who, what for, how much and where it stands; a failed one + /// says how often it was tried and when it will be again. An open or + /// failed one offers to void it. + /// + /// @param voided run after the bill was voided, to load what changed + static Container row(final Form form, final InvoiceDto invoice, String name, + final Runnable voided) { + return row(form, invoice, name, voided, true); + } + + /// One invoice. `withMember` false is the member's own page, where the name and the + /// avatar on every line would repeat the header: the plan becomes the title instead. + static Container row(final Form form, final InvoiceDto invoice, String name, + final Runnable voided, boolean withMember) { + String plan = invoice.description() == null ? "" : invoice.description(); + String suffix = " for " + invoice.memberName(); + if (invoice.memberName() != null && plan.endsWith(suffix)) { + // The backend writes " for "; the row already names the member. + plan = plan.substring(0, plan.length() - suffix.length()); + } + String detail = withMember ? plan : ""; + if ("FAILED".equals(invoice.status())) { + detail += (detail.length() > 0 ? Ui.DOT : "") + (invoice.nextAttemptAt() > 0 + ? Lang.tr("Attempt {0}, next {1}", Integer.valueOf(invoice.attempts()), + Ui.when(invoice.nextAttemptAt())) + : Lang.tr("Attempt {0}, no more retries", Integer.valueOf(invoice.attempts()))); + } else { + detail += (detail.length() > 0 ? Ui.DOT : "") + Ui.day(invoice.createdAt()); + } + Container value = new Container(BoxLayout.y()); + Label amount = Ui.plain(Ui.money(invoice.amountCents(), invoice.currency()), "SvRowAmount"); + value.add(amount); + value.add(status(invoice.status())); + Container row = withMember ? MembersForm.person(invoice.memberName(), detail, value) + : MembersForm.line(plan, detail, value); + if (!"FAILED".equals(invoice.status()) && !"OPEN".equals(invoice.status())) { + row.setName(name); + return row; + } + return Ui.tap(row, name, e -> Ui.reason(form, Lang.tr("Void invoice"), + Lang.tr("{0} for {1} will not be charged. A membership it held up runs again.", + Ui.money(invoice.amountCents(), invoice.currency()), invoice.memberName()), + Lang.tr("Void"), reason -> Api.owner().voidInvoice(invoice.id(), + new ReasonDto(reason), Net.to(done -> { + Ui.say(Lang.tr("Invoice voided")); + form.showBack(); + voided.run(); + }, Ui::fail)))); + } + + static Label status(String status) { + if ("PAID".equals(status)) { + return Ui.badge(Lang.tr("Paid"), "SvBadgeGood"); + } + if ("FAILED".equals(status)) { + return Ui.badge(Lang.tr("Failed"), "SvBadgeBad"); + } + if ("OPEN".equals(status)) { + return Ui.badge(Lang.tr("Open"), "SvBadgeWarn"); + } + if ("VOID".equals(status)) { + return Ui.badge(Lang.tr("Void"), "SvBadge"); + } + return Ui.badge(status == null ? "" : status, "SvBadge"); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/CampaignsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/CampaignsForm.java new file mode 100644 index 00000000000..2b9d881c06c --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/CampaignsForm.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.CampaignDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// Messages to members, and how each one did: how many it reached, how many +/// opened it and how many booked because of it. A campaign goes to one of +/// three audiences -- those who take a class's kind, every member, or those +/// who have not been in for a while. +public final class CampaignsForm { + public static final String CLASS = "CLASS"; + public static final String ALL = "ALL"; + public static final String LAPSED = "LAPSED"; + + private CampaignsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Campaigns", "Campaigns"); + Ui.back(form, previous); + final Container list = Ui.list("campaigns"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.owner().campaigns(Net.to(campaigns -> { + list.removeAll(); + for (int iter = 0; campaigns != null && iter < campaigns.size(); iter++) { + CampaignDto campaign = campaigns.get(iter); + Container row = Ui.row(FontImage.MATERIAL_CAMPAIGN, campaign.name(), + audience(campaign.kind()) + Ui.DOT + Ui.day(campaign.createdAt()) + "\n" + + Lang.tr("{0} sent, {1} opened, {2} booked", + Integer.valueOf(campaign.sent()), Integer.valueOf(campaign.opened()), + Integer.valueOf(campaign.booked())), null); + row.setName("campaign-" + iter); + list.add(row); + } + if (campaigns == null || campaigns.isEmpty()) { + list.add(Ui.text("No campaigns sent yet.", "SvMuted")); + } + Ui.refresh(form); + }, Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("New campaign", "newCampaign", + e -> compose(form, null)))); + form.show(); + load[0].run(); + } + + static String audience(String kind) { + return CLASS.equals(kind) ? Lang.tr("Class regulars") : LAPSED.equals(kind) + ? Lang.tr("Lapsed members") : Lang.tr("Every member"); + } + + /// The composer. With `session`, a campaign to fill that class; without + /// one, a win-back to lapsed members to start from. + public static void compose(final Form previous, final SessionDto session) { + final Form form = Ui.form("New campaign", "Compose"); + Ui.back(form, previous); + final Container page = Ui.page(); + final String[] kind = {session == null ? LAPSED : CLASS}; + final String[] sessionId = {session == null ? null : session.id()}; + final Container classes = new Container(com.codename1.ui.layouts.BoxLayout.y()); + page.add(Ui.label("Audience", "SvLabel")); + page.add(Ui.choice("audience", new String[] {Lang.tr("Class"), Lang.tr("Everyone"), + Lang.tr("Lapsed")}, new String[] {CLASS, ALL, LAPSED}, kind[0], value -> { + kind[0] = value; + classes.setHidden(!CLASS.equals(value)); + Ui.refresh(form); + })); + page.add(classes); + classes.setHidden(!CLASS.equals(kind[0])); + final TextField name = Ui.field(page, "Name, for you", "name", "Spin Tuesday push"); + final TextField title = Ui.field(page, "Title", "title", "A place is waiting"); + final TextField body = Ui.field(page, "Message", "body", + "Tuesday's spin has room. Come ride with us."); + if (session != null) { + name.setText(session.name() + " " + Ui.day(session.startsAt())); + title.setText(Lang.tr("Places left in {0}", session.name())); + body.setText(Lang.tr("{0} on {1} has room for you. Tap to book.", session.name(), + Ui.when(session.startsAt()))); + } else { + name.setText(Lang.tr("Win back")); + title.setText(Lang.tr("We miss you")); + body.setText(Lang.tr("Your next class is on us. Tap to see the timetable.")); + } + Api.studio().classes("", System.currentTimeMillis(), 7, Net.to(found -> { + List labels = new ArrayList(); + List ids = new ArrayList(); + for (int iter = 0; found != null && iter < found.size(); iter++) { + SessionDto upcoming = found.get(iter); + if (Classes.CANCELLED.equals(upcoming.state())) { + continue; + } + labels.add(upcoming.name() + " " + Ui.day(upcoming.startsAt())); + ids.add(upcoming.id()); + } + if (sessionId[0] == null && !ids.isEmpty()) { + sessionId[0] = ids.get(0); + } + if (!ids.isEmpty()) { + classes.add(Ui.choice("class", labels.toArray(new String[labels.size()]), + ids.toArray(new String[ids.size()]), sessionId[0], + value -> sessionId[0] = value, 1)); + } + Ui.refresh(form); + }, Ui::fail)); + page.add(Ui.primary("Send", "send", e -> { + CampaignDto wanted = new CampaignDto(null, name.getText().trim(), kind[0], + CLASS.equals(kind[0]) ? sessionId[0] : null, title.getText().trim(), + body.getText().trim(), 0L, 0, 0, 0); + Api.owner().send(wanted, Net.to(sent -> { + Ui.say(Lang.tr("Sent to {0} members", Integer.valueOf(sent.sent()))); + previous.showBack(); + }, Ui::fail)); + })); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/Graphs.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/Graphs.java new file mode 100644 index 00000000000..393cb848ba0 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/Graphs.java @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.pulse.api.WeekPointDto; +import com.codenameone.examples.pulse.ui.Lang; +import java.util.List; + +/// The owner's insights, drawn: the busy-hours heatmap, membership over +/// twelve weeks, the plan-mix donut and the coloured bars and swatches beside +/// them. Each takes its colours from `theme.css` through a style, so dark +/// mode is the theme's business; only the series that have a colour of their +/// own -- a class type, a plan -- are handed one. +final class Graphs { + /// The plans' colours, the same family the avatars are drawn from. + static final int[] PALETTE = {0xdc3418, 0x7c4dff, 0x1f9d6b, 0x2f6fd6, 0xe08a00, 0xc2185b}; + + private Graphs() { + } + + /// `#e4572e` or `e4572e` as a colour, or `fallback` when it is neither. + static int color(String hex, int fallback) { + if (hex == null) { + return fallback; + } + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + if (digits.length() != 6) { + return fallback; + } + int value = 0; + for (int iter = 0; iter < 6; iter++) { + int digit = Character.digit(digits.charAt(iter), 16); + if (digit < 0) { + return fallback; + } + value = value * 16 + digit; + } + return value; + } + + private static int fg(String uiid) { + return UIManager.getInstance().getComponentStyle(uiid).getFgColor(); + } + + /// Check-ins by weekday and hour: a row a day, Monday first, a cell an + /// hour, each the brand's colour as strong as the hour is busy. + static final class Heatmap extends Component { + private static final String[] DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; + private static final float CELL_MM = 4.2f; + private final int firstHour; + private final int hours; + private final int[] counts; + private int most; + + Heatmap(int firstHour, int lastHour, List busy) { + this.firstHour = firstHour; + this.hours = Math.max(1, lastHour - firstHour); + counts = new int[7 * hours]; + int peak = -1; + for (int iter = 0; busy != null && iter < busy.size() && iter < counts.length; iter++) { + Integer value = busy.get(iter); + counts[iter] = value == null ? 0 : value.intValue(); + if (counts[iter] > most) { + most = counts[iter]; + peak = iter; + } + } + setUIID("SvHeatmap"); + setName("busyHours"); + setFocusable(false); + setAccessibilityText(peak < 0 ? Lang.tr("No visits yet") + : Lang.tr("Busiest: {0} at {1}:00", Lang.tr(DAYS[peak / hours]), + Integer.valueOf(firstHour + peak % hours))); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f), 7 * CN.convertToPixels(CELL_MM) + + style.getFont().getHeight() + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + Font font = style.getFont(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int label = 0; + for (int day = 0; day < 7; day++) { + label = Math.max(label, font.stringWidth(Lang.tr(DAYS[day]))); + } + label += CN.convertToPixels(1.4f); + int cellW = Math.max(1, (width - label) / hours); + int cellH = CN.convertToPixels(CELL_MM); + int gap = Math.max(1, Math.min(cellW, cellH) / 8); + boolean rtl = isRTL(); + int grid = rtl ? left : left + label; + int labels = rtl ? left + width - label + CN.convertToPixels(1.4f) : left; + int empty = fg("SvChartGrid"); + int text = fg("SvChartAxis"); + int brand = style.getFgColor(); + g.setAntiAliased(true); + g.setFont(font); + int alpha = g.getAlpha(); + for (int day = 0; day < 7; day++) { + int y = top + day * cellH; + g.setColor(text); + g.drawString(Lang.tr(DAYS[day]), labels, y + (cellH - font.getHeight()) / 2); + for (int hour = 0; hour < hours; hour++) { + int at = rtl ? hours - 1 - hour : hour; + int x = grid + at * cellW; + int value = counts[day * hours + hour]; + g.setColor(empty); + g.fillRoundRect(x, y, cellW - gap, cellH - gap, gap * 2, gap * 2); + if (value > 0 && most > 0) { + // Never fainter than a fifth, so a quiet hour that had + // someone still reads as not empty. + g.setColor(brand); + g.setAlpha(51 + 204 * value / most); + g.fillRoundRect(x, y, cellW - gap, cellH - gap, gap * 2, gap * 2); + g.setAlpha(alpha); + } + } + } + g.setColor(text); + int foot = top + 7 * cellH; + for (int hour = 0; hour < hours; hour += 3) { + int at = rtl ? hours - 1 - hour : hour; + g.drawString(String.valueOf(firstHour + hour), grid + at * cellW, foot); + } + } + } + + /// Active members a week as a line, and under it each week's joiners as a + /// bar up and leavers as a bar down from a middle line. + static final class Membership extends Component { + private final int[] active; + private final int[] joined; + private final int[] left; + + Membership(List weeks) { + int count = weeks == null ? 0 : weeks.size(); + active = new int[count]; + joined = new int[count]; + left = new int[count]; + for (int iter = 0; iter < count; iter++) { + WeekPointDto week = weeks.get(iter); + active[iter] = week.active(); + joined[iter] = week.joined(); + left[iter] = week.left(); + } + setUIID("SvChart"); + setName("membershipChart"); + setFocusable(false); + setAccessibilityText(count == 0 ? Lang.tr("No members yet") + : Lang.tr("{0} active members, {1} twelve weeks ago", + Integer.valueOf(active[count - 1]), Integer.valueOf(active[0]))); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(40f), CN.convertToPixels(38f) + + getStyle().getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int x0 = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + int count = active.length; + if (count == 0 || width <= 0 || height <= 0) { + return; + } + int lineH = height * 3 / 5; + int barTop = top + lineH + CN.convertToPixels(2f); + int barH = top + height - barTop; + int middle = barTop + barH / 2; + int slot = Math.max(1, width / count); + int start = x0 + (width - slot * count) / 2; + boolean rtl = isRTL(); + g.setAntiAliased(true); + g.setColor(fg("SvChartGrid")); + g.drawLine(x0, top + lineH, x0 + width, top + lineH); + g.drawLine(x0, middle, x0 + width, middle); + int hi = 1; + int lo = Integer.MAX_VALUE; + int churn = 1; + for (int iter = 0; iter < count; iter++) { + hi = Math.max(hi, active[iter]); + lo = Math.min(lo, active[iter]); + churn = Math.max(churn, Math.max(joined[iter], left[iter])); + } + // The line starts from a little under the lowest week, so a studio + // that grew by ten members does not read as flat. + int floor = Math.max(0, lo - Math.max(1, (hi - lo) / 3)); + int span = Math.max(1, hi - floor); + int bar = Math.max(1, slot / 2); + int dot = CN.convertToPixels(1.2f); + int px = 0; + int py = 0; + for (int iter = 0; iter < count; iter++) { + int at = rtl ? count - 1 - iter : iter; + int cx = start + at * slot + slot / 2; + int up = (barH / 2 - 1) * joined[iter] / churn; + int down = (barH / 2 - 1) * left[iter] / churn; + g.setColor(fg("SvLegendJoined")); + if (up > 0) { + g.fillRect(cx - bar / 2, middle - up, bar, up); + } + g.setColor(fg("SvLegendLeft")); + if (down > 0) { + g.fillRect(cx - bar / 2, middle + 1, bar, down); + } + int cy = top + lineH - 1 - (lineH - dot) * (active[iter] - floor) / span; + g.setColor(style.getFgColor()); + if (iter > 0) { + g.drawLine(px, py, cx, cy); + g.drawLine(px, py + 1, cx, cy + 1); + } + g.fillArc(cx - dot / 2, cy - dot / 2, dot, dot, 0, 360); + px = cx; + py = cy; + } + } + } + + /// The plans as slices of a ring, the members on each the slice's size, + /// and the total in the middle. + static final class Donut extends Component { + private final int[] values; + private final int total; + + Donut(int[] values, String spoken) { + this.values = values; + int sum = 0; + for (int iter = 0; iter < values.length; iter++) { + sum += Math.max(0, values[iter]); + } + total = sum; + setUIID("SvDonut"); + setName("planDonut"); + setFocusable(false); + setAccessibilityText(spoken); + } + + @Override + protected Dimension calcPreferredSize() { + int size = CN.convertToPixels(30f); + return new Dimension(size + getStyle().getHorizontalPadding(), + size + getStyle().getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + int size = Math.min(width, height); + if (size <= 0) { + return; + } + int x = getX() + style.getPaddingLeftNoRTL() + (width - size) / 2; + int y = getY() + style.getPaddingTop() + (height - size) / 2; + g.setAntiAliased(true); + if (total <= 0) { + g.setColor(fg("SvChartGrid")); + g.fillArc(x, y, size, size, 0, 360); + } else { + int angle = 90; + for (int iter = 0; iter < values.length; iter++) { + int sweep = iter == values.length - 1 ? 450 - angle + : Math.round(360f * Math.max(0, values[iter]) / total); + g.setColor(PALETTE[iter % PALETTE.length]); + g.fillArc(x, y, size, size, angle, sweep); + angle += sweep; + } + } + int hole = size * 3 / 5; + g.setColor(UIManager.getInstance().getComponentStyle("SvPage").getBgColor()); + g.fillArc(x + (size - hole) / 2, y + (size - hole) / 2, hole, hole, 0, 360); + Font font = style.getFont(); + String text = String.valueOf(total); + g.setFont(font); + g.setColor(style.getFgColor()); + g.drawString(text, x + (size - font.stringWidth(text)) / 2, + y + (size - font.getHeight()) / 2); + } + } + + /// A dot of one colour: what a legend line is the key of. + static final class Swatch extends Component { + private final int color; + + Swatch(int color) { + this.color = color; + setUIID("Container"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + int size = CN.convertToPixels(3f); + return new Dimension(size + CN.convertToPixels(2f), size); + } + + @Override + public void paint(Graphics g) { + int size = getHeight(); + g.setAntiAliased(true); + g.setColor(color); + g.fillArc(isRTL() ? getX() + getWidth() - size : getX(), getY(), size, size, 0, 360); + } + } + + /// A rounded bar filled to `share` of its track, in a colour of its own. + static final class Bar extends Component { + private final double share; + private final int color; + + Bar(double share, int color) { + this.share = share < 0 ? 0 : share > 1 ? 1 : share; + this.color = color; + setUIID("SvRankBar"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(30), + CN.convertToPixels(1.6f) + getStyle().getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int x = getX() + style.getPaddingLeftNoRTL(); + int y = getY() + style.getPaddingTop(); + int w = getWidth() - style.getHorizontalPadding(); + int h = getHeight() - style.getVerticalPadding(); + if (w <= 0 || h <= 0) { + return; + } + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + g.fillRoundRect(x, y, w, h, h, h); + int filled = Math.max(h, (int) (w * share)); + g.setColor(color); + g.fillRoundRect(isRTL() ? x + w - filled : x, y, filled, h, h, h); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/InsightsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/InsightsForm.java new file mode 100644 index 00000000000..c56562c93f8 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/InsightsForm.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.CoachStatDto; +import com.codenameone.examples.pulse.api.InsightsDto; +import com.codenameone.examples.pulse.api.PlanMixDto; +import com.codenameone.examples.pulse.api.TypeFillDto; +import com.codenameone.examples.pulse.api.WeekPointDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Layouts; +import com.codenameone.examples.pulse.ui.Skeleton; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Twelve weeks of the studio, for the owner: when people come, whether the +/// membership grows, how each kind of class fills, what the plans bring in +/// and how each coach's month went. Everything is the server's arithmetic; +/// this screen only draws it. +public final class InsightsForm { + private InsightsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Insights", "OwnerInsights"); + Ui.back(form, previous); + final Container page = Ui.page(); + page.setName("insights"); + Layouts.broaden(page); + page.add(new Skeleton(6)); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.owner().insights(Net.to(found -> fill(form, page, found), + Ui.failed(page, load[0]))); + Ui.pull(page, load[0]); + form.add(BorderLayout.CENTER, page); + form.show(); + load[0].run(); + } + + private static void fill(Form form, Container page, InsightsDto insights) { + page.removeAll(); + StatRow tiles = Ui.stats(Layouts.wide() ? 4 : 2); + tiles.stat(insights.retention90() + "%", "Still with us after 90 days", "retention") + .stat(Ui.tenths(insights.visitsPerMemberTenths() / 10.0), "Visits a member a week", + "visitsPerMember") + .stat(insights.noShowPercent() + "%", "No-shows", "noShows") + .stat(Ui.moneyShort(insights.revenuePerMemberCents(), null), "Revenue a member", + "revenuePerMember"); + page.add(tiles); + + // The studio's rhythm on the left, what it sells and who teaches it on + // the right, on a wide window. + Container[] halves = Layouts.halves(page); + Container left = halves[0]; + Container right = halves[1]; + Ui.section(left, "Busy hours"); + left.add(Ui.text("Check-ins over the last eight weeks.", "SvMuted")); + left.add(new Graphs.Heatmap(insights.firstHour(), insights.lastHour(), + insights.busyHours())); + + Ui.section(left, "Membership"); + membership(left, insights.weeks()); + + Ui.section(right, "Classes"); + classes(right, insights.classes()); + + Ui.section(right, "Plan mix"); + plans(right, insights.plans()); + + Ui.section(right, "Coaches"); + coaches(right, insights.coaches()); + Ui.refresh(form); + } + + private static void membership(Container page, List weeks) { + if (weeks == null || weeks.isEmpty()) { + page.add(Ui.text("No members yet.", "SvMuted")); + return; + } + WeekPointDto now = weeks.get(weeks.size() - 1); + page.add(Ui.plain(String.valueOf(now.active()), "SvChartValue")); + int change = now.active() - weeks.get(0).active(); + page.add(Ui.text(change >= 0 + ? Lang.tr("{0} more than twelve weeks ago", Integer.valueOf(change)) + : Lang.tr("{0} fewer than twelve weeks ago", Integer.valueOf(-change)), "SvMuted")); + page.add(new Graphs.Membership(weeks)); + Container axis = new Container(new BorderLayout()); + axis.add(BorderLayout.WEST, Ui.plain(OwnerForm.shortDate(weeks.get(0).weekOf()), + "SvChartAxis")); + axis.add(BorderLayout.EAST, Ui.label("This week", "SvChartAxis")); + page.add(axis); + page.add(FlowLayout.encloseIn(Ui.label("Active", "SvLegendActive"), + Ui.label("Joined", "SvLegendJoined"), Ui.label("Left", "SvLegendLeft"))); + } + + private static void classes(Container page, List types) { + if (types == null || types.isEmpty()) { + page.add(Ui.text("No classes in the last four weeks.", "SvMuted")); + return; + } + Container list = new Container(BoxLayout.y()); + list.setName("classFill"); + for (int iter = 0; iter < types.size(); iter++) { + TypeFillDto type = types.get(iter); + Container line = new Container(new BorderLayout(), "SvRankRow"); + line.add(BorderLayout.CENTER, Ui.plain(type.name(), "SvRowTitle")); + line.add(BorderLayout.EAST, Ui.plain(Lang.tr("{0}% full, {1}% no-show", + Integer.valueOf(type.fillPercent()), Integer.valueOf(type.noShowPercent())), + "SvRowDetail")); + list.add(line); + Graphs.Bar bar = new Graphs.Bar(type.fillPercent() / 100.0, + Graphs.color(type.color(), Graphs.PALETTE[iter % Graphs.PALETTE.length])); + bar.setAccessibilityText(Lang.tr("{0}: {1}% full", type.name(), + Integer.valueOf(type.fillPercent()))); + list.add(bar); + } + page.add(list); + } + + private static void plans(Container page, List plans) { + if (plans == null || plans.isEmpty()) { + page.add(Ui.text("No plans sold yet.", "SvMuted")); + return; + } + int[] members = new int[plans.size()]; + StringBuilder spoken = new StringBuilder(); + for (int iter = 0; iter < members.length; iter++) { + members[iter] = plans.get(iter).members(); + if (iter > 0) { + spoken.append(", "); + } + spoken.append(Lang.tr("{0}: {1} members", plans.get(iter).name(), + Integer.valueOf(members[iter]))); + } + page.add(FlowLayout.encloseCenter(new Graphs.Donut(members, spoken.toString()))); + for (int iter = 0; iter < members.length; iter++) { + PlanMixDto plan = plans.get(iter); + Container row = new Container(new BorderLayout(), "SvLegendRow"); + row.setName("plan-" + iter); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + new Graphs.Swatch(Graphs.PALETTE[iter % Graphs.PALETTE.length]))); + Container lines = new Container(BoxLayout.y()); + lines.add(Ui.plain(plan.name(), "SvRowTitle")); + lines.add(Ui.plain(Lang.tr("{0} members", Integer.valueOf(plan.members())), + "SvRowDetail")); + row.add(BorderLayout.CENTER, lines); + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(Ui.plain( + // A pack is paid once, so it brings in nothing a month; + // the rest round to whole units, as a legend reads. + plan.monthlyCents() == 0 ? Lang.tr("Paid per pack") + : Lang.tr("{0} a month", Ui.moneyShort( + (plan.monthlyCents() + 50) / 100 * 100, null)), + "SvRowAmount"))); + page.add(row); + } + } + + private static void coaches(Container page, List coaches) { + if (coaches == null || coaches.isEmpty()) { + page.add(Ui.text("No classes taught in the last four weeks.", "SvMuted")); + return; + } + for (int iter = 0; iter < coaches.size(); iter++) { + CoachStatDto coach = coaches.get(iter); + Container row = MembersForm.person(coach.name(), + Lang.tr("{0} classes, {1}% full, {2}% show up", + Integer.valueOf(coach.classes()), Integer.valueOf(coach.fillPercent()), + Integer.valueOf(coach.showPercent())), + Ui.badge(Lang.tr("{0} kudos", Integer.valueOf(coach.kudos())), "SvBadgeGood")); + row.setName("coach-" + iter); + page.add(row); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MemberDetailForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MemberDetailForm.java new file mode 100644 index 00000000000..c5f94aa9dd0 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MemberDetailForm.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.InvoiceDto; +import com.codenameone.examples.pulse.api.MemberDetailDto; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.OwnerMemberDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Chart; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Skeleton; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// One member as the owner sees them: where their membership stands, how +/// often they come, what they come for and every bill they have had. +public final class MemberDetailForm { + private MemberDetailForm() { + } + + public static void show(final Form previous, final String id) { + final Form form = Ui.form("Member", "OwnerMember"); + Ui.back(form, previous); + final Container page = Ui.page(); + form.add(BorderLayout.CENTER, page); + form.show(); + into(form, page, id); + } + + /// Draws the member `id` into `page`, which is on `form`: a screen of its + /// own on a phone, the right of the members list on a wide window. + public static void into(final Form form, final Container page, final String id) { + page.setName("member"); + page.removeAll(); + page.add(new Skeleton(6)); + page.revalidate(); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.owner().member(id, Net.to(found -> { + // Another member picked while this one was on its way wins. + if (id.equals(page.getClientProperty("showing"))) { + fill(form, page, found, load[0]); + } + }, Ui.failed(page, load[0]))); + page.putClientProperty("showing", id); + Ui.pull(page, load[0]); + load[0].run(); + } + + private static void fill(Form form, Container page, MemberDetailDto detail, Runnable load) { + page.removeAll(); + OwnerMemberDto member = detail.member(); + Container head = new Container(new BorderLayout(), "SvPanel"); + head.setName("memberHeader"); + head.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(Ui.avatar(member.name()))); + Container names = new Container(BoxLayout.y()); + names.add(Ui.plain(member.name(), "SvHeading")); + if (member.planName() != null) { + names.add(Ui.plain(member.planName(), "SvMuted")); + } + // On a line of its own: plan and date together did not fit beside the pill. + names.add(Ui.plain(Lang.tr("Joined {0}", Ui.day(member.joinedAt())), "SvMuted")); + head.add(BorderLayout.CENTER, names); + head.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(MembersForm.state(member))); + page.add(head); + + StatRow tiles = Ui.stats(3); + tiles.stat(String.valueOf(member.visits30()), "Visits in 30 days", "visits30") + .stat(String.valueOf(detail.streakWeeks()), "Week streak", "streak") + .stat(Ui.moneyShort(detail.lifetimeCents(), null), "Lifetime value", "lifetime"); + page.add(tiles); + + Ui.section(page, "Visits a week"); + List weeks = detail.visitsByWeek(); + double[] values = new double[weeks == null ? 0 : weeks.size()]; + for (int iter = 0; iter < values.length; iter++) { + Integer value = weeks.get(iter); + values[iter] = value == null ? 0 : value.intValue(); + } + Container chart = Chart.of("memberVisits", values, Lang.tr("11 weeks ago"), + Lang.tr("This week")); + page.add(chart); + + Ui.section(page, "Favourite classes"); + List favourites = detail.favourites(); + for (int iter = 0; favourites != null && iter < favourites.size(); iter++) { + NameCountDto favourite = favourites.get(iter); + Container row = new Container(new BorderLayout(), "SvRankRow"); + row.add(BorderLayout.CENTER, Ui.plain(favourite.name(), "SvRowTitle")); + row.add(BorderLayout.EAST, Ui.plain(Lang.tr("{0} visits", + Integer.valueOf(favourite.count())), "SvRowDetail")); + page.add(row); + } + if (favourites == null || favourites.isEmpty()) { + page.add(Ui.text("No classes yet.", "SvMuted")); + } + + Ui.section(page, "Invoices"); + List invoices = detail.invoices(); + for (int iter = 0; invoices != null && iter < invoices.size(); iter++) { + page.add(BillingForm.row(form, invoices.get(iter), "invoice-" + iter, load, false)); + } + if (invoices == null || invoices.isEmpty()) { + page.add(Ui.empty(FontImage.MATERIAL_RECEIPT_LONG, Lang.tr("No invoices"), null)); + } + Ui.refresh(form); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MembersForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MembersForm.java new file mode 100644 index 00000000000..2a72e8ac075 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/MembersForm.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.OwnerMemberDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Layouts; +import com.codenameone.examples.pulse.ui.Skeleton; +import com.codenameone.examples.pulse.ui.Split; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The studio's members, searchable by name and narrowed by a row of chips: +/// who is at risk of lapsing, whose card failed, who is frozen, new or gone. +/// A member leads to everything the owner knows about them. +public final class MembersForm { + /// The chips' filters, as the server names them; empty is everyone. + private static final String[] FILTERS = {"", "AT_RISK", "PAST_DUE", "FROZEN", "NEW", + "CANCELLED"}; + private static final String[] FILTER_NAMES = {"All", "At risk", "Past due", "Frozen", "New", + "Cancelled"}; + private static final long DAY = 86400000L; + + private final Form form = Ui.form("Members", "OwnerMembers"); + private final Container list = Ui.list("members"); + private final TextField search = new TextField("", Lang.tr("Search by name"), 20, + TextField.ANY); + private String filter = ""; + /// The list and the member picked side by side, on a wide window; null on a phone. + private Split split; + /// The member open beside the list, kept while the list is searched. + private String opened; + private List members; + + private MembersForm() { + } + + public static void show(Form previous) { + new MembersForm().open(previous); + } + + private void open(Form previous) { + Ui.back(form, previous); + search.setUIID("SvSearchField"); + search.getHintLabel().setUIID("SvFieldHint"); + search.setName("memberSearch"); + search.addDataChangedListener((type, index) -> fill()); + String[] names = new String[FILTERS.length]; + String[] values = new String[FILTERS.length]; + for (int iter = 0; iter < names.length; iter++) { + names[iter] = Lang.tr(FILTER_NAMES[iter]); + // A chip's name is `filter-` and this: "ALL" reads better than nothing. + values[iter] = FILTERS[iter].length() == 0 ? "ALL" : FILTERS[iter]; + } + Container top = new Container(BoxLayout.y(), "SvSearchBar"); + top.add(search); + top.add(Ui.pills("filter", names, values, "ALL", value -> { + filter = "ALL".equals(value) ? "" : value; + list.removeAll(); + list.add(new Skeleton(5)); + list.revalidate(); + load(); + })); + if (Layouts.wide()) { + split = new Split(form, top, list); + } else { + form.add(BorderLayout.NORTH, top); + form.add(BorderLayout.CENTER, list); + } + Ui.pull(list, this::load); + form.show(); + // The list is what an owner came for; the search waits to be tapped + // rather than showing focused on arrival. Showing focuses the first + // focusable component, so the focus is let go once that is done. + CN.callSerially(() -> form.setFocused(null)); + load(); + } + + private void load() { + final String asked = filter; + Api.owner().members(asked, Net.to(found -> { + // A chip tapped while the last answer was on its way wins. + if (asked.equals(filter)) { + members = found; + fill(); + } + }, Ui.failed(list, this::load))); + } + + private void fill() { + if (members == null) { + return; + } + list.removeAll(); + if (split != null) { + split.clear(); + } + String wanted = search.getText().trim(); + int shown = 0; + for (int iter = 0; iter < members.size(); iter++) { + final OwnerMemberDto member = members.get(iter); + if (!contains(member.name(), wanted)) { + continue; + } + String detail = (member.planName() == null ? "" : member.planName() + Ui.DOT) + + lastVisit(member.lastVisit()) + Ui.DOT + + Lang.tr("{0} in 30 days", Integer.valueOf(member.visits30())); + final Container row = person(member.name(), detail, state(member)); + list.add(Ui.tap(row, "memberRow" + shown, e -> open(row, member))); + if (split != null && member.id().equals(opened)) { + split.pick(row); + } else if (split != null && opened == null && shown == 0) { + // Beside the list there is always someone: the first, to begin with. + open(row, member); + } + shown++; + } + if (shown == 0) { + list.add(Ui.empty(FontImage.MATERIAL_PERSON_SEARCH, wanted.length() > 0 + ? Lang.tr("Nobody by that name") : Lang.tr("No members here"), + Lang.tr("Try another filter."))); + } + Ui.refresh(form); + } + + /// Opens `member`: beside the list on a wide window, on a screen of its + /// own on a phone. + private void open(Container row, OwnerMemberDto member) { + if (split == null) { + MemberDetailForm.show(form, member.id()); + return; + } + opened = member.id(); + split.pick(row); + MemberDetailForm.into(form, split.detail(), member.id()); + } + + /// Whether `text` has `part` in it, whatever the case: compared a + /// character at a time, which no device language changes. + static boolean contains(String text, String part) { + if (part.length() == 0) { + return true; + } + if (text == null) { + return false; + } + for (int at = 0; at + part.length() <= text.length(); at++) { + if (text.regionMatches(true, at, part, 0, part.length())) { + return true; + } + } + return false; + } + + /// A row about a person: their initials, their name as it is, a line under + /// it and `value` at the end. + public static Container person(String name, String detail, Component value) { + Container row = line(name, detail, value); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(Ui.avatar(name))); + return row; + } + + /// A row of a title, a detail line and a value on the trailing edge, with no avatar. + public static Container line(String name, String detail, Component value) { + Container row = new Container(new BorderLayout(), "SvRow"); + Container lines = new Container(BoxLayout.y()); + lines.add(Ui.plain(name, "SvRowTitle")); + if (detail != null && detail.length() > 0) { + lines.add(Ui.text(detail, "SvRowDetail")); + } + row.add(BorderLayout.CENTER, lines); + if (value != null) { + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(value)); + } + return row; + } + + /// The pill that says where a member stands; at risk outranks active. + static Label state(OwnerMemberDto member) { + String state = member.state(); + if ("PAST_DUE".equals(state)) { + return Ui.badge(Lang.tr("Past due"), "SvBadgeBad"); + } + if ("CANCELLED".equals(state)) { + return Ui.badge(Lang.tr("Cancelled"), "SvBadge"); + } + if ("FROZEN".equals(state)) { + return Ui.badge(Lang.tr("Frozen"), "SvBadge"); + } + if (member.away()) { + return Ui.badge(Lang.tr("Away"), "SvBadge"); + } + if (member.atRisk()) { + return Ui.badge(Lang.tr("At risk"), "SvBadgeWarn"); + } + return Ui.badge(Lang.tr("Active"), "SvBadgeGood"); + } + + /// When someone was last in: "Today", "Yesterday", "3 days ago". + static String lastVisit(long at) { + if (at <= 0) { + return Lang.tr("Never in"); + } + long days = (System.currentTimeMillis() - at) / DAY; + if (days <= 0) { + return Lang.tr("In today"); + } + if (days == 1) { + return Lang.tr("Yesterday"); + } + return Lang.tr("{0} days ago", Long.valueOf(days)); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/OwnerForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/OwnerForm.java new file mode 100644 index 00000000000..73134711011 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/OwnerForm.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.views.BarChart; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.pulse.api.DayAmountDto; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.OwnerDashboardDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Layouts; +import com.codenameone.examples.pulse.ui.Menu; +import com.codenameone.examples.pulse.ui.Nav; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The studio owner's home, which is the dashboard: what the studio earns, +/// how full it is, who is drifting away and which classes carry it. The menu +/// leads to plans, class templates, the timetable, staff, shifts, campaigns and +/// the accounts. +public class OwnerForm implements Nav.Home { + /// The colours of the charts' series. The charts draw themselves and + /// read no theme, so they are given the brand's colours here. + private static final int[] PALETTE = {0xe4572e, 0x17bebb, 0xffc914, 0x2e282a, 0x76b041, + 0x7e6b8f}; + + private final Form form = Ui.home("Owner"); + private final Menu menu = new Menu(form, Nav.OWNER); + private final Container page = Ui.page(); + private final StatRow stats = Ui.stats(Layouts.wide() ? 4 : 2); + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + menu.item(FontImage.MATERIAL_INSIGHTS, "Dashboard", "dashboard", () -> form.showBack()); + menu.item(FontImage.MATERIAL_QUERY_STATS, "Insights", "insights", + () -> InsightsForm.show(form)); + menu.item(FontImage.MATERIAL_GROUP, "Members", "members", () -> MembersForm.show(form)); + menu.item(FontImage.MATERIAL_RECEIPT_LONG, "Billing", "billing", + () -> BillingForm.show(form)); + menu.item(FontImage.MATERIAL_CARD_MEMBERSHIP, "Plans and pricing", "ownerPlans", + () -> PlansForm.show(form)); + menu.item(FontImage.MATERIAL_CATEGORY, "Class templates", "templates", + () -> TemplatesForm.show(form)); + menu.item(FontImage.MATERIAL_EVENT, "Timetable", "timetable", + () -> TimetableForm.show(form)); + menu.item(FontImage.MATERIAL_PEOPLE, "Staff", "staff", () -> StaffForm.show(form)); + menu.item(FontImage.MATERIAL_SCHEDULE, "Shifts", "ownerShifts", + () -> StaffForm.shifts(form)); + menu.item(FontImage.MATERIAL_CAMPAIGN, "Campaigns", "campaigns", + () -> CampaignsForm.show(form)); + menu.item(FontImage.MATERIAL_MANAGE_ACCOUNTS, "Accounts", "accounts", + () -> AccountsForm.show(form)); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, menu.flatButton()); + bar.add(BorderLayout.CENTER, Ui.label("Studio", "SvTitle")); + page.setName("owner"); + Layouts.broaden(page); + page.setScrollableY(true); + Ui.pull(page, this::load); + Container shell = new Container(new BorderLayout()); + shell.add(BorderLayout.NORTH, bar); + shell.add(BorderLayout.CENTER, page); + form.add(shell); + menu.install(); + form.addShowListener(e -> load()); + } + + private void load() { + Api.owner().dashboard(Net.to(this::fill, Ui::fail)); + } + + private void fill(OwnerDashboardDto board) { + page.removeAll(); + stats.clear(); + stats.stat(Ui.moneyShort(board.mrrCents(), null), "Monthly recurring", "mrr") + .stat(String.valueOf(board.activeMembers()), "Members", "members") + .stat(board.utilisation() + "%", "Places taken", "utilisation") + .stat(String.valueOf(board.atRisk()), "At risk", "atRisk"); + page.add(stats); + // The rest of the month at a glance, as tiles rather than a sentence. + StatRow month = Ui.stats(Layouts.wide() ? 4 : 2); + month.stat(String.valueOf(board.newMembers30()), "Joined in 30 days", "joined") + .stat(String.valueOf(board.churned30()), "Left in 30 days", "left") + .stat(String.valueOf(board.checkIns7()), "Visits this week", "checkIns7") + .stat(String.valueOf(board.pastDue()), "Invoices past due", "pastDue"); + page.add(month); + // On a wide window the money and the classes sit side by side; on a + // phone both halves are the page itself, one under the other. + Container[] halves = Layouts.halves(page); + Container money = halves[0]; + Container links = halves[1]; + // The deeper screens, each from the figure that sends an owner there. + links.add(Ui.tap(Ui.row(FontImage.MATERIAL_QUERY_STATS, Lang.tr("Insights"), + Lang.tr("Busy hours, growth, classes and coaches"), null), "insightsButton", + e -> InsightsForm.show(form))); + links.add(Ui.tap(Ui.row(FontImage.MATERIAL_GROUP, Lang.tr("Members"), + Lang.tr("{0} at risk", Integer.valueOf(board.atRisk())), null), "membersButton", + e -> MembersForm.show(form))); + links.add(Ui.tap(Ui.row(FontImage.MATERIAL_RECEIPT_LONG, Lang.tr("Billing"), + Lang.tr("{0} invoices past due", Integer.valueOf(board.pastDue())), + board.pastDue() > 0 ? Ui.badge(String.valueOf(board.pastDue()), "SvBadgeBad") + : null), "billingButton", e -> BillingForm.show(form))); + + // Takings: everything paid, packs and drop-ins too, which is why it + // is not the recurring figure above. + Ui.section(money, "Takings"); + money.add(Ui.text(Lang.tr("{0} in the last 30 days", Ui.moneyShort(board.revenue30Cents(), null)), + "SvMuted")); + money.add(sized(revenue(board.revenueByDay()), "revenueChart")); + + Ui.section(money, "Popular classes"); + if (board.popularClasses() == null || board.popularClasses().isEmpty()) { + money.add(Ui.text("No bookings in the last month.", "SvMuted")); + } else { + money.add(popular(board.popularClasses())); + } + + Ui.section(links, "Under-filled this week"); + List under = board.underfilled(); + for (int iter = 0; under != null && iter < under.size(); iter++) { + final SessionDto session = under.get(iter); + links.add(Ui.tap(Ui.row(FontImage.MATERIAL_TRENDING_DOWN, session.name(), + Ui.when(session.startsAt()), Ui.badge(session.booked() + "/" + + session.capacity(), "SvBadgeWarn")), "under-" + iter, + e -> CampaignsForm.compose(form, session))); + } + if (under == null || under.isEmpty()) { + links.add(Ui.text("Every class is filling up.", "SvMuted")); + } + if (board.atRisk() > 0) { + links.add(Ui.secondary(board.atRisk() == 1 ? Lang.tr("Win back a lapsing member") + : Lang.tr("Win back {0} lapsing members", + Integer.valueOf(board.atRisk())), "winBack", + e -> CampaignsForm.compose(form, null))); + } + Ui.refresh(form); + } + + private static Component sized(Component chart, String name) { + chart.setName(name); + chart.setPreferredH(CN.convertToPixels(45)); + return chart; + } + + /// The last 30 days of takings, a bar a day, in whole currency units. + static Component revenue(List days) { + XYSeries series = new XYSeries(Lang.tr("Revenue")); + XYMultipleSeriesRenderer renderer = renderer(); + double most = 0; + for (int iter = 0; days != null && iter < days.size(); iter++) { + double amount = days.get(iter).amount() / 100.0; + series.add(iter + 1, amount); + most = Math.max(most, amount); + if (iter % 7 == 0) { + String date = days.get(iter).date(); + renderer.addXTextLabel(iter + 1, shortDate(date)); + } + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + data.addSeries(series); + XYSeriesRenderer bars = new XYSeriesRenderer(); + bars.setColor(PALETTE[0] | 0xff000000); + renderer.addSeriesRenderer(bars); + renderer.setXAxisMin(0); + renderer.setXAxisMax(days == null ? 1 : days.size() + 1); + renderer.setYAxisMin(0); + renderer.setYAxisMax(most <= 0 ? 1 : most * 1.1); + renderer.setXLabels(0); + renderer.setBarSpacing(0.3); + return new ChartComponent(new BarChart(data, renderer, BarChart.Type.DEFAULT)); + } + + private static XYMultipleSeriesRenderer renderer() { + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setShowLegend(false); + renderer.setLabelsTextSize(CN.convertToPixels(2.2f)); + renderer.setMargins(new int[] {CN.convertToPixels(2), CN.convertToPixels(8), + CN.convertToPixels(2), CN.convertToPixels(2)}); + // The margins are painted, and 0 paints them black: give them the + // page's own colour, which follows dark mode. + renderer.setMarginsColor(0xff000000 | com.codename1.ui.plaf.UIManager.getInstance() + .getComponentStyle("SvPage").getBgColor()); + renderer.setApplyBackgroundColor(false); + renderer.setLabelsColor(0xff7a7a7a); + renderer.setXLabelsColor(0xff7a7a7a); + renderer.setYLabelsColor(0, 0xff7a7a7a); + renderer.setAxesColor(0xffbdbdbd); + renderer.setPanEnabled(false, false); + renderer.setZoomEnabled(false, false); + return renderer; + } + + private static final String[] MONTHS = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", + "Aug", "Sep", "Oct", "Nov", "Dec"}; + + /// `2026-10-01` as "Oct 1". + static String shortDate(String date) { + if (date == null || date.length() < 10) { + return date == null ? "" : date; + } + int month = (date.charAt(5) - '0') * 10 + date.charAt(6) - '0'; + int day = (date.charAt(8) - '0') * 10 + date.charAt(9) - '0'; + return month < 1 || month > 12 ? date : Lang.tr(MONTHS[month - 1]) + " " + day; + } + + /// The most booked kinds of class, ranked, each with a bar for its share + /// of the most booked one: easier to read on a phone than a pie, and no + /// label falls off the edge. + public static Component popular(List classes) { + Container list = new Container(BoxLayout.y()); + list.setName("popularChart"); + int most = 1; + for (int iter = 0; iter < classes.size(); iter++) { + most = Math.max(most, classes.get(iter).count()); + } + for (int iter = 0; iter < classes.size(); iter++) { + NameCountDto row = classes.get(iter); + Container line = new Container(new BorderLayout(), "SvRankRow"); + line.add(BorderLayout.CENTER, Ui.plain(row.name(), "SvRowTitle")); + line.add(BorderLayout.EAST, Ui.plain(Lang.tr("{0} booked", + Integer.valueOf(row.count())), "SvRowDetail")); + list.add(line); + list.add(new Share(row.count() / (double) most, + PALETTE[iter % PALETTE.length])); + } + return list; + } + + /// A rounded bar filled to `share` of the width. + private static final class Share extends Component { + private final double share; + private final int color; + + Share(double share, int color) { + this.share = share; + this.color = color; + setUIID("SvRankBar"); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(30), + CN.convertToPixels(1.6f) + getStyle().getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + int x = getX() + getStyle().getPaddingLeftNoRTL(); + int y = getY() + getStyle().getPaddingTop(); + int w = getWidth() - getStyle().getHorizontalPadding(); + int h = getHeight() - getStyle().getVerticalPadding(); + g.setAntiAliased(true); + g.setColor(getStyle().getFgColor()); + g.fillRoundRect(x, y, w, h, h, h); + g.setColor(color); + g.fillRoundRect(x, y, Math.max(h, (int) (w * share)), h, h, h); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/PlansForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/PlansForm.java new file mode 100644 index 00000000000..dd39ec391e9 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/PlansForm.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.components.Switch; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.PlanDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The membership plans the studio sells, and an editor for their price and +/// terms. A plan is never deleted -- members are on it -- only retired. +public final class PlansForm { + static final String[] KINDS = {"MONTHLY", "ANNUAL", "CLASS_PACK", "DROP_IN"}; + private static final String[] KIND_NAMES = {"Monthly", "Annual", "Class pack", "Drop-in"}; + + private PlansForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Plans and pricing", "OwnerPlans"); + Ui.back(form, previous); + final Container list = Ui.list("plans"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.owner().plans(Net.to(plans -> fill(form, list, plans, load[0]), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("New plan", "newPlan", + e -> edit(form, null, load[0])))); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List plans, + final Runnable load) { + list.removeAll(); + for (int iter = 0; plans != null && iter < plans.size(); iter++) { + final PlanDto plan = plans.get(iter); + list.add(Ui.tap(Ui.row(FontImage.MATERIAL_CARD_MEMBERSHIP, plan.name(), + Ui.money(plan.priceCents(), plan.currency()) + Ui.DOT + kindName(plan.kind()), + plan.active() ? null : Ui.badge(Lang.tr("Retired"), "SvBadgeBad")), + "plan-" + iter, e -> edit(form, plan, load))); + } + Ui.refresh(form); + } + + static String kindName(String kind) { + for (int iter = 0; iter < KINDS.length; iter++) { + if (KINDS[iter].equals(kind)) { + return Lang.tr(KIND_NAMES[iter]); + } + } + return kind; + } + + private static void edit(final Form previous, final PlanDto plan, final Runnable saved) { + final Form form = Ui.form(plan == null ? "New plan" : plan.name(), "PlanEditor"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", "Unlimited monthly"); + final TextField about = Ui.field(page, "Description", "description", + "What the plan includes"); + final String[] kind = {plan == null ? KINDS[0] : plan.kind()}; + page.add(Ui.label("Kind", "SvLabel")); + String[] names = new String[KINDS.length]; + for (int iter = 0; iter < names.length; iter++) { + names[iter] = Lang.tr(KIND_NAMES[iter]); + } + page.add(Ui.choice("kind", names, KINDS, kind[0], value -> kind[0] = value, 2)); + final TextField price = Ui.field(page, "Price", "price", "49.00", TextArea.DECIMAL); + final TextField months = Ui.field(page, "Months per period", "months", "1", + TextArea.NUMERIC); + final TextField credits = Ui.field(page, "Classes included (0 for unlimited)", "credits", + "0", TextArea.NUMERIC); + final boolean[] offPeak = {plan != null && plan.offPeakOnly()}; + final boolean[] active = {plan == null || plan.active()}; + Switch offPeakSwitch = Ui.toggle("offPeak", offPeak[0], on -> offPeak[0] = on); + Switch activeSwitch = Ui.toggle("active", active[0], on -> active[0] = on); + page.add(Ui.row((char) 0, Lang.tr("Off-peak only"), null, offPeakSwitch)); + page.add(Ui.row((char) 0, Lang.tr("On sale"), null, activeSwitch)); + if (plan != null) { + name.setText(plan.name()); + about.setText(plan.description()); + price.setText(cents(plan.priceCents())); + months.setText(String.valueOf(plan.periodMonths())); + credits.setText(String.valueOf(plan.classCredits())); + } + page.add(Ui.primary("Save", "save", e -> { + long priceCents = parseCents(price.getText()); + if (name.getText().trim().length() == 0 || priceCents < 0) { + Ui.say(Lang.tr("A plan needs a name and a price.")); + return; + } + PlanDto wanted = new PlanDto(plan == null ? null : plan.id(), name.getText().trim(), + about.getText().trim(), kind[0], priceCents, + plan == null ? null : plan.currency(), number(months.getText(), 1), + number(credits.getText(), 0), plan == null ? 0 : plan.minAge(), + plan == null ? 0 : plan.maxAge(), offPeak[0], active[0]); + Api.owner().savePlan(wanted, Net.to(done -> { + Ui.say(Lang.tr("Plan saved")); + previous.showBack(); + saved.run(); + }, Ui::fail)); + })); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// `cents` written as whole units and two decimals. + static String cents(long cents) { + long part = cents % 100; + return (cents / 100) + "." + (part < 10 ? "0" : "") + part; + } + + /// A price typed in units, in cents; -1 when it is not a price. + static long parseCents(String text) { + String value = text == null ? "" : text.trim(); + if (value.length() == 0) { + return -1; + } + try { + return Math.round(Double.parseDouble(value) * 100); + } catch (NumberFormatException wrong) { + return -1; + } + } + + static int number(String text, int fallback) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException wrong) { + return fallback; + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/StaffForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/StaffForm.java new file mode 100644 index 00000000000..6f0ed526b67 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/StaffForm.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.Component; +import com.codename1.ui.Sheet; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.api.StaffDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.trainer.ShiftsForm; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The studio's staff and what each may do, and the rota of shifts. Roles +/// are switches: the server keeps a trainer with classes still to teach from +/// losing the trainer role, and says so. +public final class StaffForm { + static final String[] ROLES = {"TRAINER", "DESK", "OWNER"}; + private static final String[] ROLE_NAMES = {"Trainer", "Front desk", "Owner"}; + private static final long HOUR = 3600000L; + + private StaffForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Staff", "Staff"); + Ui.back(form, previous); + final Container list = Ui.list("staff"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.owner().staff(Net.to(staff -> fill(form, list, staff, load[0]), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(Form form, Container list, List staff, + final Runnable load) { + list.removeAll(); + for (int iter = 0; staff != null && iter < staff.size(); iter++) { + final StaffDto person = staff.get(iter); + // One row a person, their roles as chips; the switches that change + // them are a tap away, in a sheet, rather than three rows each. + Container chips = new Container(new FlowLayout(Component.RIGHT)); + for (int role = 0; role < ROLES.length; role++) { + if (person.roles() != null && person.roles().contains(ROLES[role])) { + chips.add(Ui.badge(Lang.tr(ROLE_NAMES[role]), "SvBadge")); + } + } + Container row = Ui.row((char) 0, person.name(), person.title() + Ui.DOT + + Lang.tr("{0} classes this week", Integer.valueOf(person.classesThisWeek())), + chips); + row.add(BorderLayout.WEST, Ui.avatar(person.name())); + final int index = iter; + list.add(Ui.tap(row, "staff-" + iter, e -> roles(person, index, load))); + } + Ui.refresh(form); + } + + private static void roles(final StaffDto person, int iter, final Runnable load) { + final Sheet sheet = new Sheet(null, person.name()); + sheet.getContentPane().setLayout(BoxLayout.y()); + for (int role = 0; role < ROLES.length; role++) { + final String name = ROLES[role]; + boolean has = person.roles() != null && person.roles().contains(name); + sheet.getContentPane().add(Ui.row((char) 0, Lang.tr(ROLE_NAMES[role]), null, + Ui.toggle("role-" + iter + "-" + name, has, on -> { + sheet.back(); + setRole(person, name, on, load); + }))); + } + sheet.show(); + } + + private static void setRole(StaffDto person, String role, boolean on, final Runnable load) { + List roles = new ArrayList(); + if (person.roles() != null) { + roles.addAll(person.roles()); + } + roles.remove(role); + if (on) { + roles.add(role); + } + Api.owner().saveStaff(new StaffDto(person.username(), person.name(), person.title(), + person.bio(), person.locationId(), person.certifiedUntil(), roles, + person.classesThisWeek()), Net.to(saved -> Ui.say(Lang.tr("Roles saved")), + (status, message) -> { + Ui.fail(status, message); + load.run(); + })); + } + + /// Every location's shifts to come, and a way to add one. + public static void shifts(final Form previous) { + final Form form = Ui.form("Shifts", "OwnerShifts"); + Ui.back(form, previous); + final Container list = Ui.list("ownerShifts"); + final Runnable[] load = new Runnable[1]; + // The locations are asked for once, for their names: a studio with + // two sites shows each shift's. + final Map sites = new HashMap(); + load[0] = () -> Api.studio().locations(Net.to(found -> { + sites.clear(); + for (int iter = 0; found != null && iter < found.size(); iter++) { + sites.put(found.get(iter).id(), found.get(iter).name()); + } + Api.owner().shifts("", Net.to(shifts -> rota(form, list, shifts, sites), + Ui.failed(list, load[0]))); + }, Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("Add a shift", "addShift", + e -> addShift(form, load[0])))); + form.show(); + load[0].run(); + } + + /// The rota a day at a time: who works, what, when and where. + private static void rota(Form form, Container list, List shifts, + Map sites) { + list.removeAll(); + String day = null; + for (int iter = 0; shifts != null && iter < shifts.size(); iter++) { + ShiftDto shift = shifts.get(iter); + String heading = Ui.weekday(shift.startsAt()); + if (!heading.equals(day)) { + day = heading; + Ui.section(list, heading); + } + String site = sites.size() > 1 ? sites.get(shift.locationId()) : null; + list.add(MembersForm.person(shift.staffName(), shift.duty() + Ui.DOT + + ShiftsForm.hours(shift) + (site == null ? "" : Ui.DOT + site), + ShiftsForm.SWAP_REQUESTED.equals(shift.state()) + ? Ui.badge(Lang.tr("Offered"), "SvBadgeWarn") : null)); + } + if (shifts == null || shifts.isEmpty()) { + list.add(Ui.text("No shifts on the rota.", "SvMuted")); + } + Ui.refresh(form); + } + + private static void addShift(final Form previous, final Runnable added) { + final Form form = Ui.form("Add a shift", "AddShift"); + Ui.back(form, previous); + final Container page = Ui.page(); + form.add(BorderLayout.CENTER, page); + form.show(); + Api.owner().staff(Net.to(staff -> { + if (staff == null || staff.isEmpty()) { + page.add(Ui.text("Nobody on the staff yet.", "SvMuted")); + Ui.refresh(form); + return; + } + String[] names = new String[staff.size()]; + String[] ids = new String[staff.size()]; + for (int iter = 0; iter < names.length; iter++) { + names[iter] = staff.get(iter).name(); + ids[iter] = staff.get(iter).username(); + } + final String[] who = {ids[0]}; + page.add(Ui.label("Who", "SvLabel")); + page.add(Ui.choice("who", names, ids, who[0], value -> who[0] = value, 2)); + final TextField duty = Ui.field(page, "Duty", "duty", "Front desk"); + page.add(Ui.label("Starts", "SvLabel")); + final Picker starts = new Picker(); + starts.setType(Display.PICKER_TYPE_DATE_AND_TIME); + starts.setName("starts"); + starts.setUIID("SvField"); + page.add(starts); + final TextField hours = Ui.field(page, "Hours", "hours", "4", TextArea.NUMERIC); + page.add(Ui.primary("Add", "save", e -> { + String location = ""; + for (int iter = 0; iter < staff.size(); iter++) { + if (staff.get(iter).username().equals(who[0])) { + location = staff.get(iter).locationId(); + } + } + long from = starts.getDate().getTime(); + long to = from + Math.max(1, PlansForm.number(hours.getText(), 4)) * HOUR; + Api.owner().addShift(new ShiftDto(null, location, who[0], null, + duty.getText().trim(), from, to, ShiftsForm.ASSIGNED), Net.to(done -> { + Ui.say(Lang.tr("Shift added")); + previous.showBack(); + added.run(); + }, Ui::fail)); + })); + Ui.refresh(form); + }, Ui::fail)); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TemplatesForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TemplatesForm.java new file mode 100644 index 00000000000..0b468f59334 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TemplatesForm.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.components.Switch; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The kinds of class the studio teaches. A template holds what every class +/// of its kind shares -- length, intensity, places, ages -- so scheduling one +/// is a time, a room and a trainer. +public final class TemplatesForm { + private TemplatesForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Class templates", "Templates"); + Ui.back(form, previous); + final Container list = Ui.list("templates"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.studio().classTypes(Net.to(types -> fill(form, list, types, load[0]), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("New template", "newTemplate", + e -> edit(form, null, load[0])))); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List types, + final Runnable load) { + list.removeAll(); + for (int iter = 0; types != null && iter < types.size(); iter++) { + final ClassTypeDto type = types.get(iter); + Container row = Ui.row((char) 0, type.name(), + Lang.tr("{0} min", Integer.valueOf(type.minutes())) + Ui.DOT + + Lang.tr("{0} places", Integer.valueOf(type.capacity())) + Ui.DOT + + Lang.tr("intensity {0} of 5", Integer.valueOf(type.intensity())), + type.active() ? null : Ui.badge(Lang.tr("Retired"), "SvBadgeBad")); + row.add(BorderLayout.WEST, com.codenameone.examples.pulse.member.Classes.tile( + type.category(), type.color())); + list.add(Ui.tap(row, "template-" + iter, e -> edit(form, type, load))); + } + Ui.refresh(form); + } + + private static void edit(final Form previous, final ClassTypeDto type, final Runnable saved) { + final Form form = Ui.form(type == null ? "New template" : type.name(), "TemplateEditor"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", "Spin 45"); + final TextField about = Ui.field(page, "Description", "description", + "What the class is like"); + final TextField category = Ui.field(page, "Category", "category", "Cardio"); + final TextField minutes = Ui.field(page, "Minutes", "minutes", "45", TextArea.NUMERIC); + final TextField capacity = Ui.field(page, "Places", "capacity", "20", TextArea.NUMERIC); + final TextField intensity = Ui.field(page, "Intensity, 1 to 5", "intensity", "3", + TextArea.NUMERIC); + final TextField minAge = Ui.field(page, "Youngest age (0 for any)", "minAge", "0", + TextArea.NUMERIC); + final TextField maxAge = Ui.field(page, "Oldest age (0 for any)", "maxAge", "0", + TextArea.NUMERIC); + final TextField color = Ui.field(page, "Colour", "color", "#e4572e"); + final boolean[] active = {type == null || type.active()}; + Switch activeSwitch = Ui.toggle("active", active[0], on -> active[0] = on); + page.add(Ui.row((char) 0, Lang.tr("Bookable"), null, activeSwitch)); + if (type != null) { + name.setText(type.name()); + about.setText(type.description()); + category.setText(type.category()); + minutes.setText(String.valueOf(type.minutes())); + capacity.setText(String.valueOf(type.capacity())); + intensity.setText(String.valueOf(type.intensity())); + minAge.setText(String.valueOf(type.minAge())); + maxAge.setText(String.valueOf(type.maxAge())); + color.setText(type.color()); + } + page.add(Ui.primary("Save", "save", e -> { + if (name.getText().trim().length() == 0) { + Ui.say(Lang.tr("A template needs a name.")); + return; + } + ClassTypeDto wanted = new ClassTypeDto(type == null ? null : type.id(), + name.getText().trim(), about.getText().trim(), category.getText().trim(), + PlansForm.number(intensity.getText(), 3), PlansForm.number(minAge.getText(), 0), + PlansForm.number(maxAge.getText(), 0), PlansForm.number(capacity.getText(), 20), + PlansForm.number(minutes.getText(), 45), color.getText().trim(), active[0]); + Api.owner().saveClassType(wanted, Net.to(done -> { + Ui.say(Lang.tr("Template saved")); + previous.showBack(); + saved.run(); + }, Ui::fail)); + })); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TimetableForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TimetableForm.java new file mode 100644 index 00000000000..1c9da19ea10 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/owner/TimetableForm.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.owner; + +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.spinner.Picker; +import com.codenameone.examples.pulse.api.ClassTypeDto; +import com.codenameone.examples.pulse.api.NewSessionDto; +import com.codenameone.examples.pulse.api.ReasonDto; +import com.codenameone.examples.pulse.api.RoomDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.TrainerDto; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +/// The coming week's timetable across every location, from where the owner +/// schedules a class, cancels one or hands it to another trainer. +public final class TimetableForm { + private static final long DAY = 24L * 3600000L; + + private TimetableForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Timetable", "Timetable"); + Ui.back(form, previous); + final Container list = Ui.list("timetable"); + final Runnable[] load = new Runnable[1]; + // From the start of today, so the morning's classes are still there to + // check and nothing from yesterday leads the list. + Calendar today = Calendar.getInstance(); + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + final long from = today.getTime().getTime(); + load[0] = () -> Api.studio().classes("", from, 8, + Net.to(classes -> fill(form, list, classes, load[0]), Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("Schedule a class", "scheduleClass", + e -> schedule(form, load[0])))); + form.show(); + load[0].run(); + } + + private static void fill(final Form form, Container list, List classes, + final Runnable load) { + list.removeAll(); + String day = null; + for (int iter = 0; classes != null && iter < classes.size(); iter++) { + final SessionDto session = classes.get(iter); + String today = Ui.day(session.startsAt()); + if (!today.equals(day)) { + Ui.section(list, today); + day = today; + } + boolean cancelled = Classes.CANCELLED.equals(session.state()); + // The members' card, with the room's fill where they have a + // booking button, and the location as "who" since an owner runs + // more than one. + list.add(Ui.tap(Classes.card(session, Ui.badge(cancelled ? Lang.tr("Cancelled") + : session.booked() + "/" + session.capacity(), + cancelled ? "SvBadgeBad" : "SvBadge"), null, session.locationName()), + "session-" + iter, e -> manage(form, session, load))); + } + if (classes == null || classes.isEmpty()) { + list.add(Ui.text("Nothing on the timetable this week.", "SvMuted")); + } + Ui.refresh(form); + } + + /// One class: cancel it, with a reason the members are told, or give it + /// to another trainer. + private static void manage(final Form previous, final SessionDto session, + final Runnable changed) { + final Form form = Ui.form(session.name(), "ManageClass"); + Ui.back(form, previous); + final Container page = Ui.page(); + page.add(Ui.fact("When", Ui.when(session.startsAt()))); + page.add(Ui.fact("Where", session.locationName() + Ui.DOT + session.roomName())); + page.add(Ui.fact("Trainer", session.trainerName())); + page.add(Ui.fact("Booked", session.booked() + "/" + session.capacity())); + if (!Classes.CANCELLED.equals(session.state())) { + Ui.section(page, "Substitute"); + Api.studio().trainers(Net.to(trainers -> { + for (int iter = 0; trainers != null && iter < trainers.size(); iter++) { + final TrainerDto trainer = trainers.get(iter); + if (trainer.username().equals(session.trainer())) { + continue; + } + page.add(Ui.tap(Ui.row(FontImage.MATERIAL_PERSON, trainer.name(), + trainer.title(), null), "sub-" + iter, + e -> Api.owner().substitute(session.id(), trainer.username(), + Net.to(done -> { + Ui.say(Lang.tr("{0} takes the class", trainer.name())); + previous.showBack(); + changed.run(); + }, Ui::fail)))); + } + page.add(Ui.danger("Cancel class", "cancelClass", e -> Ui.reason(form, + "Cancel class", Lang.tr("Everyone booked is told, with your reason, " + + "and their credit is returned."), "Cancel class", + reason -> Api.owner().cancelClass(session.id(), new ReasonDto(reason), + Net.to(done -> { + Ui.say(Lang.tr("Class cancelled")); + previous.showBack(); + changed.run(); + }, Ui::fail))))); + Ui.refresh(form); + }, Ui::fail)); + } + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// Puts a class on the timetable: a template, a room, a trainer and a + /// first time, repeated weekly. + static void schedule(final Form previous, final Runnable scheduled) { + final Form form = Ui.form("Schedule a class", "ScheduleClass"); + Ui.back(form, previous); + final Container page = Ui.page(); + page.setName("scheduleClass"); + form.add(BorderLayout.CENTER, page); + form.show(); + Api.studio().classTypes(Net.to(types -> Api.owner().rooms(Net.to(rooms -> + Api.studio().trainers(Net.to(trainers -> { + build(form, page, previous, types, rooms, trainers, scheduled); + }, Ui::fail)), Ui::fail)), Ui::fail)); + } + + private static void build(final Form form, Container page, final Form previous, + List types, List rooms, List trainers, + final Runnable scheduled) { + if (types == null || types.isEmpty() || rooms == null || rooms.isEmpty() + || trainers == null || trainers.isEmpty()) { + page.add(Ui.text("Add a class template, a room and a trainer first.", "SvMuted")); + Ui.refresh(form); + return; + } + final String[] type = {types.get(0).id()}; + final String[] room = {rooms.get(0).id()}; + final String[] trainer = {trainers.get(0).username()}; + page.add(Ui.label("Class", "SvLabel")); + page.add(Ui.choice("type", names(types, null, null), ids(types, null, null), type[0], + value -> type[0] = value, 2)); + page.add(Ui.label("Room", "SvLabel")); + page.add(Ui.choice("room", names(null, rooms, null), ids(null, rooms, null), room[0], + value -> room[0] = value, 2)); + page.add(Ui.label("Trainer", "SvLabel")); + page.add(Ui.choice("trainer", names(null, null, trainers), ids(null, null, trainers), + trainer[0], value -> trainer[0] = value, 2)); + page.add(Ui.label("First class", "SvLabel")); + final Picker when = new Picker(); + when.setType(Display.PICKER_TYPE_DATE_AND_TIME); + when.setName("startsAt"); + when.setUIID("SvField"); + Calendar tomorrow = Calendar.getInstance(); + tomorrow.setTime(new Date(System.currentTimeMillis() + DAY)); + tomorrow.set(Calendar.HOUR_OF_DAY, 18); + tomorrow.set(Calendar.MINUTE, 0); + tomorrow.set(Calendar.SECOND, 0); + tomorrow.set(Calendar.MILLISECOND, 0); + when.setDate(tomorrow.getTime()); + page.add(when); + final TextField capacity = Ui.field(page, "Places (blank for the template's)", "capacity", + "", TextArea.NUMERIC); + final TextField weeks = Ui.field(page, "Weeks", "weeks", "1", TextArea.NUMERIC); + weeks.setText("1"); + page.add(Ui.primary("Schedule", "schedule", e -> { + NewSessionDto wanted = new NewSessionDto(type[0], room[0], trainer[0], + when.getDate().getTime(), PlansForm.number(capacity.getText(), 0), + Math.max(1, PlansForm.number(weeks.getText(), 1))); + Api.owner().schedule(wanted, Net.to(made -> { + Ui.say(Lang.tr("{0} classes scheduled", + Integer.valueOf(made == null ? 0 : made.size()))); + previous.showBack(); + scheduled.run(); + }, Ui::fail)); + })); + Ui.refresh(form); + } + + private static String[] names(List types, List rooms, + List trainers) { + int size = types != null ? types.size() : rooms != null ? rooms.size() : trainers.size(); + String[] out = new String[size]; + for (int iter = 0; iter < size; iter++) { + out[iter] = types != null ? types.get(iter).name() : rooms != null + ? rooms.get(iter).name() : trainers.get(iter).name(); + } + return out; + } + + private static String[] ids(List types, List rooms, + List trainers) { + int size = types != null ? types.size() : rooms != null ? rooms.size() : trainers.size(); + String[] out = new String[size]; + for (int iter = 0; iter < size; iter++) { + out[iter] = types != null ? types.get(iter).id() : rooms != null + ? rooms.get(iter).id() : trainers.get(iter).username(); + } + return out; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/CardForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/CardForm.java new file mode 100644 index 00000000000..989cc13c32b --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/CardForm.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.CardDto; +import com.codenameone.examples.pulse.api.PaymentSetupDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; + +/// Adds a card. +/// +/// How depends on who takes the payments, and the server says which when the +/// setup starts. A real provider has a page of its own for typing a card into: +/// the app opens it in the browser, never sees the number, and asks the server +/// what was saved when the user comes back. The simulated provider a +/// development server uses has no such page, so the card is typed here -- which +/// is also what lets the whole flow be tried, and tested, with no account +/// anywhere. +public final class CardForm { + /// The setup the browser was opened for, to finish when the app is back. + private static CardForm waiting; + /// What a hosted setup is finished with: the card went to the processor's + /// own page, and nothing of it is here to send. + private static final CardDto NO_CARD = new CardDto(null, 0, 0, null, null); + + private final Form form = Ui.form("Add a card", "Card"); + private final Form previous; + private final Runnable added; + private final Container page = Ui.page(); + private final SpanLabel error = Ui.text("", "SvError"); + private String setupId; + + private CardForm(Form previous, Runnable added) { + this.previous = previous; + this.added = added; + Ui.back(form, previous); + error.setName("error"); + form.add(BorderLayout.CENTER, page); + } + + /// @param added run once the card is in the wallet; it is what shows the + /// next screen + public static void show(Form previous, Runnable added) { + final CardForm card = new CardForm(previous, added); + card.form.show(); + Api.payments().startSetup(Net.to(card::begin, (status, message) -> { + Ui.fail(status, message); + previous.showBack(); + })); + } + + /// The app is in front again. If it left to add a card, see what came of it. + public static void resumed() { + if (waiting != null) { + waiting.finish(NO_CARD); + } + } + + private void begin(PaymentSetupDto setup) { + setupId = setup.id(); + if (setup.hosted()) { + hosted(setup.url()); + } else { + typed(); + } + Ui.refresh(form); + } + + private void hosted(final String url) { + page.add(Ui.banner(FontImage.MATERIAL_LOCK, "Your card goes straight to the bank", + "The payment provider's own page opens in your browser. Pulse never sees " + + "the number. Come back here when you have finished.", "SvBannerIcon")); + page.add(error); + page.add(Ui.primary("Open the secure page", "openHosted", e -> { + waiting = this; + CN.execute(url); + })); + page.add(Ui.secondary("I have finished", "finishHosted", e -> finish(NO_CARD))); + } + + private void typed() { + final Label number = Ui.plain(mask(""), "SvCardNumber"); + final Label holder = Ui.plain("", "SvCardSmall"); + final Label expires = Ui.plain("MM/YY", "SvCardSmall"); + final Label brand = Ui.plain("", "SvCardBrand"); + Container face = new Container(new BorderLayout()); + face.setUIID("SvCardFace"); + face.add(BorderLayout.NORTH, brand); + face.add(BorderLayout.CENTER, number); + Container foot = new Container(new BorderLayout()); + foot.add(BorderLayout.CENTER, holder); + foot.add(BorderLayout.EAST, expires); + face.add(BorderLayout.SOUTH, foot); + page.add(face); + + final TextField digits = Ui.field(page, "Card number", "cardNumber", + "4242 4242 4242 4242", TextArea.NUMERIC); + Container pair = new Container(new GridLayout(1, 2)); + Container left = new Container(BoxLayout.y()); + Container right = new Container(BoxLayout.y()); + left.setUIID("SvPairLeft"); + right.setUIID("SvPairRight"); + final TextField expiry = Ui.field(left, "Expires", "cardExpiry", "MM/YY", + TextArea.NUMERIC); + final TextField cvc = Ui.field(right, "Security code", "cardCvc", "123", + TextArea.NUMERIC); + pair.add(left).add(right); + page.add(pair); + final TextField name = Ui.field(page, "Name on the card", "cardHolder", ""); + // Each field is written back in the shape a card has it in. Writing it + // back is itself a change, so the listener stops when there is nothing + // left to correct. + digits.addDataChangedListener((type, index) -> { + String shaped = Cards.group(digits.getText()); + if (!shaped.equals(digits.getText())) { + digits.setText(shaped); + } + number.setText(mask(Cards.digits(shaped))); + brand.setText(Cards.brand(Cards.digits(shaped))); + face.revalidate(); + }); + expiry.addDataChangedListener((type, index) -> { + String shaped = Cards.expiry(expiry.getText()); + if (!shaped.equals(expiry.getText())) { + expiry.setText(shaped); + } + expires.setText(shaped.length() == 0 ? "MM/YY" : shaped); + face.revalidate(); + }); + name.addDataChangedListener((type, index) -> { + holder.setText(name.getText()); + face.revalidate(); + }); + page.add(Ui.text("This server takes no real payments. Use the test card " + + "4242 4242 4242 4242 with any date ahead and any three digits.", "SvNotice")); + page.add(error); + page.add(Ui.primary("Save card", "saveCard", e -> { + String wrong = Cards.check(digits.getText(), expiry.getText(), cvc.getText()); + if (wrong != null) { + error.setText(Lang.tr(wrong)); + Ui.refresh(form); + return; + } + CardDto card = new CardDto(Cards.digits(digits.getText()), + Cards.month(expiry.getText()), Cards.year(expiry.getText()), + cvc.getText().trim(), name.getText().trim()); + finish(card); + })); + } + + private void finish(CardDto card) { + waiting = null; + error.setText(""); + Api.payments().completeSetup(setupId, card, Net.to(method -> { + Telemetry.cardAdded(); + added.run(); + }, + (status, message) -> { + error.setText(message); + Ui.refresh(form); + })); + } + + /// The number as the card shows it: what is typed, and dots for the rest. + private static String mask(String digits) { + StringBuilder shown = new StringBuilder(); + for (int iter = 0; iter < 16; iter++) { + if (iter > 0 && iter % 4 == 0) { + shown.append(" "); + } + shown.append(iter < digits.length() ? digits.charAt(iter) : '*'); + } + return shown.toString(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/Cards.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/Cards.java new file mode 100644 index 00000000000..f55bd046ceb --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/Cards.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +/// What a card number, an expiry date and a security code look like. +/// +/// Checking them here is a courtesy: it says "that is not a card number" before +/// a request is made. The decision is the provider's, which checks again and +/// is the only one who knows whether the card is real. +public final class Cards { + private Cards() { + } + + /// Only the digits of `text`. + public static String digits(String text) { + StringBuilder digits = new StringBuilder(); + for (int iter = 0; text != null && iter < text.length(); iter++) { + char letter = text.charAt(iter); + if (letter >= '0' && letter <= '9') { + digits.append(letter); + } + } + return digits.toString(); + } + + /// The digits of `text` in fours, as a card has them: `4242 4242`. + public static String group(String text) { + String digits = digits(text); + if (digits.length() > 19) { + digits = digits.substring(0, 19); + } + StringBuilder grouped = new StringBuilder(); + for (int iter = 0; iter < digits.length(); iter++) { + if (iter > 0 && iter % 4 == 0) { + grouped.append(' '); + } + grouped.append(digits.charAt(iter)); + } + return grouped.toString(); + } + + /// The digits of `text` as a month and a year: `0428` is `04/28`. + public static String expiry(String text) { + String digits = digits(text); + if (digits.length() > 4) { + digits = digits.substring(0, 4); + } + return digits.length() <= 2 ? digits : digits.substring(0, 2) + "/" + digits.substring(2); + } + + public static int month(String expiry) { + String digits = digits(expiry); + return digits.length() < 2 ? 0 : Integer.parseInt(digits.substring(0, 2)); + } + + /// The year in full: a card says `28` and means 2028. + public static int year(String expiry) { + String digits = digits(expiry); + return digits.length() < 4 ? 0 : 2000 + Integer.parseInt(digits.substring(2, 4)); + } + + /// The network a number belongs to, from how it starts; empty while that + /// cannot be told. + public static String brand(String digits) { + if (digits.startsWith("4")) { + return "Visa"; + } + if (digits.startsWith("34") || digits.startsWith("37")) { + return "Amex"; + } + if (digits.startsWith("6011") || digits.startsWith("65")) { + return "Discover"; + } + if (digits.length() >= 2) { + int two = Integer.parseInt(digits.substring(0, 2)); + if (two >= 51 && two <= 55 || two >= 22 && two <= 27) { + return "Mastercard"; + } + } + return ""; + } + + /// What is wrong with what was typed, in English, or null when nothing is. + public static String check(String number, String expiry, String cvc) { + String digits = digits(number); + if (digits.length() < 12 || digits.length() > 19 || !luhn(digits)) { + return "That is not a card number. Check the digits."; + } + int month = month(expiry); + if (month < 1 || month > 12 || year(expiry) == 0) { + return "Write the expiry date as month and year: 04/28."; + } + String code = cvc == null ? "" : cvc.trim(); + if (code.length() < 3 || code.length() > 4 || digits(code).length() != code.length()) { + return "The security code is the three or four digits on the card."; + } + return null; + } + + /// The check digit every card number ends in, which catches a mistyped one. + public static boolean luhn(String digits) { + int sum = 0; + boolean twice = false; + for (int iter = digits.length() - 1; iter >= 0; iter--) { + int digit = digits.charAt(iter) - '0'; + if (twice) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + twice = !twice; + } + return digits.length() > 0 && sum % 10 == 0; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/WalletForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/WalletForm.java new file mode 100644 index 00000000000..fa9c28f70d4 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/pay/WalletForm.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.pay; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.PaymentMethodDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The ways the member can pay: their cards, and cash at the desk. +/// +/// The list is the server's. A card is added by the payment provider and what +/// comes back is a brand and four digits; the number itself is never kept by +/// this app, and by the server only long enough to hand it over. +/// +/// The same screen answers "which of these, for this payment?": opened with +/// [#pick], a tap on a method chooses it and goes back. +public final class WalletForm { + public interface Picked { + void picked(PaymentMethodDto method); + } + + private final Form form; + private final Form previous; + private final Picked picked; + private final String chosen; + private final Container list = new Container(BoxLayout.y()); + + private WalletForm(Form previous, String chosen, Picked picked) { + this.previous = previous; + this.picked = picked; + this.chosen = chosen; + form = Ui.form(picked == null ? "Wallet" : "Pay with", "Wallet"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text(picked == null + ? "Tap a card to make it the one that pays unless you say otherwise." + : "Choose how to pay.", "SvMuted")); + list.setName("methods"); + page.add(list); + page.add(Ui.secondary("Add a card", "addCard", + e -> CardForm.show(form, () -> { + form.showBack(); + load(); + }))); + form.add(BorderLayout.CENTER, page); + } + + /// The wallet, to look after. + public static void show(Form previous) { + WalletForm wallet = new WalletForm(previous, null, null); + wallet.form.show(); + wallet.load(); + } + + /// The wallet, to choose from for one payment. + /// + /// @param chosen the id of the method chosen now + public static void pick(Form previous, String chosen, Picked picked) { + WalletForm wallet = new WalletForm(previous, chosen, picked); + wallet.form.show(); + wallet.load(); + } + + /// The name of a method, as a customer would say it. + public static String label(PaymentMethodDto method) { + if (method == null || "cash".equals(method.kind())) { + return Lang.tr("Cash"); + } + return method.label() == null || method.label().length() == 0 + ? method.brand() + " " + method.last4() : method.label(); + } + + private void load() { + Api.payments().methods(Net.to(this::fill, Ui::fail)); + } + + private void fill(List methods) { + list.removeAll(); + for (int iter = 0; methods != null && iter < methods.size(); iter++) { + final PaymentMethodDto method = methods.get(iter); + boolean cash = "cash".equals(method.kind()); + boolean marked = picked == null ? method.isDefault() : method.id().equals(chosen); + Container row = new Container(new BorderLayout()); + row.setUIID(marked ? "SvOptionPicked" : "SvOption"); + Label icon = new Label("", "SvOptionIcon"); + Ui.icon(icon, cash ? FontImage.MATERIAL_PAYMENTS + : FontImage.MATERIAL_CREDIT_CARD, 4f); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(icon)); + Container lines = new Container(BoxLayout.y()); + lines.add(Ui.plain(label(method), "SvRowTitle")); + lines.add(Ui.plain(cash ? Lang.tr("Pay at the front desk") + : Lang.tr("Expires {0}", expiry(method)), "SvRowDetail")); + row.add(BorderLayout.CENTER, lines); + Container end = new Container(new FlowLayout(Label.RIGHT, Label.CENTER)); + if (method.isDefault()) { + end.add(Ui.badge("Default", "SvBadgeGood")); + } + row.add(BorderLayout.EAST, end); + Ui.tap(row, cash ? "method-cash" : "method-" + iter, e -> choose(method)); + list.add(row); + if (!cash && picked == null) { + // Its own line under the card, so that a tap meant for the card + // is never a tap on Remove. + Button remove = Ui.link("Remove this card", "remove-" + iter, e -> + Api.payments().remove(method.id(), Net.to(gone -> { + Telemetry.cardRemoved(); + if (method.id().equals(Prefs.paymentMethod())) { + Prefs.setPaymentMethod(Prefs.PAYMENT_CASH); + } + load(); + }, Ui::fail))); + list.add(FlowLayout.encloseRight(remove)); + } + } + Ui.refresh(form); + } + + private void choose(final PaymentMethodDto method) { + if (picked != null) { + previous.showBack(); + picked.picked(method); + return; + } + Api.payments().makeDefault(method.id(), Net.to(made -> { + Prefs.setPaymentMethod(method.id()); + load(); + }, Ui::fail)); + } + + private static String expiry(PaymentMethodDto method) { + int year = method.expYear() % 100; + return (method.expMonth() < 10 ? "0" : "") + method.expMonth() + "/" + + (year < 10 ? "0" : "") + year; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/LiveClassForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/LiveClassForm.java new file mode 100644 index 00000000000..d2cb9880408 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/LiveClassForm.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.pulse.api.RosterEntryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The class as it happens: a tile per person in the room, coloured by the +/// heart rate zone their strap or watch last reported. Readings arrive over +/// [LiveChannel] as members stream them; nothing here polls. +public final class LiveClassForm extends LiveChannel.Adapter { + private final SessionDto session; + private final Form form; + private final Container grid = new Container(new GridLayout(2)); + private final Container empty = new Container(BoxLayout.y()); + private final Map zones = new HashMap(); + private final Map rates = new HashMap(); + private final Map tiles = new HashMap(); + + private LiveClassForm(Form previous, SessionDto session) { + this.session = session; + form = Ui.form(session.name(), "LiveClass"); + Ui.back(form, previous); + grid.setName("zones"); + Container page = Ui.page(); + page.add(Ui.text(Lang.tr("Heart rate zones, live. Zone 5 is flat out."), "SvMuted")); + page.add(grid); + page.add(empty); + form.add(BorderLayout.CENTER, page); + form.addShowListener(e -> { + LiveChannel.listen(this); + load(); + }); + } + + public static void show(Form previous, SessionDto session) { + new LiveClassForm(previous, session).form.show(); + } + + private void load() { + Api.trainer().roster(session.id(), Net.to(this::fill, Ui::fail)); + } + + private void fill(List roster) { + grid.removeAll(); + empty.removeAll(); + zones.clear(); + rates.clear(); + tiles.clear(); + for (int iter = 0; roster != null && iter < roster.size(); iter++) { + RosterEntryDto entry = roster.get(iter); + if (Classes.WAITLISTED.equals(entry.state()) || Classes.CANCELLED.equals(entry.state())) { + continue; + } + Container tile = new Container(BoxLayout.y()); + tile.setName("zone-" + entry.memberId()); + tile.add(Ui.label(entry.name(), "SvZoneName")); + Label rate = Ui.label("", "SvZoneRate"); + tile.add(rate); + Label zone = Ui.label("", "SvZoneName"); + tile.add(zone); + zones.put(entry.memberId(), zone); + rates.put(entry.memberId(), rate); + tiles.put(entry.memberId(), tile); + show(entry.memberId(), entry.heartRate(), entry.zone()); + grid.add(tile); + } + if (rates.isEmpty()) { + // Beside the grid, not in it: a cell of the grid is half the width. + empty.add(Ui.empty(FontImage.MATERIAL_FAVORITE, Lang.tr("Nobody in yet"), + Lang.tr("Members appear here once they are booked."))); + } + Ui.refresh(form); + } + + private void show(String memberId, int bpm, int zone) { + Label rate = rates.get(memberId); + Container tile = tiles.get(memberId); + if (rate == null) { + return; + } + rate.setText(bpm > 0 ? bpm + " bpm" : "--"); + zones.get(memberId).setText(bpm > 0 ? Lang.tr("Zone {0}", + Integer.valueOf(Math.max(1, Math.min(5, zone)))) : Lang.tr("No reading")); + tile.setUIID(bpm > 0 ? "SvZone" + Math.max(1, Math.min(5, zone)) : "SvZone0"); + } + + @Override + public void heartRate(String sessionId, String memberId, int bpm, int zone) { + if (!session.id().equals(sessionId)) { + return; + } + if (!rates.containsKey(memberId)) { + // Someone checked in after the view was opened. + load(); + return; + } + show(memberId, bpm, zone); + grid.repaint(); + } + + @Override + public void bookingChanged(String id, String state) { + load(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/RosterForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/RosterForm.java new file mode 100644 index 00000000000..77b06826f00 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/RosterForm.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.components.Switch; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.AttendanceDto; +import com.codenameone.examples.pulse.api.RosterEntryDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.owner.MembersForm; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// Who is booked into a class, with what the trainer should know before it +/// starts: a first visit, a medical note. A switch per person marks them +/// present, for someone the door did not see. +public final class RosterForm { + private RosterForm() { + } + + public static void show(final Form previous, final SessionDto session) { + final Form form = Ui.form(session.name(), "Roster"); + Ui.back(form, previous); + final Container list = Ui.list("roster"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.trainer().roster(session.id(), Net.to( + roster -> fill(form, list, session, roster), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.add(BorderLayout.SOUTH, Ui.dock(Ui.primary("Live view", "live", + e -> LiveClassForm.show(form, session)))); + form.show(); + load[0].run(); + } + + private static void fill(Form form, Container list, SessionDto session, + List roster) { + list.removeAll(); + list.add(Ui.text(Ui.when(session.startsAt()) + Ui.DOT + session.roomName(), "SvMuted")); + Ui.section(list, "Booked"); + int queued = 0; + for (int iter = 0; roster != null && iter < roster.size(); iter++) { + final RosterEntryDto entry = roster.get(iter); + if (Classes.WAITLISTED.equals(entry.state())) { + queued++; + continue; + } + if (Classes.CANCELLED.equals(entry.state())) { + continue; + } + StringBuilder detail = new StringBuilder(); + if (entry.firstVisit()) { + // A newcomer is who a coach greets first, so it leads the line. + detail.append(Lang.tr("First visit")).append(Ui.DOT); + } + detail.append(Lang.tr("Age {0}", Integer.valueOf(entry.age()))); + if (entry.heartRate() > 0) { + detail.append(Ui.DOT).append(entry.heartRate()).append(" bpm"); + } + if (entry.medicalNote() != null && entry.medicalNote().length() > 0) { + detail.append('\n').append(Lang.tr("Medical: {0}", entry.medicalNote())); + } + final Switch present = Ui.toggle("present-" + entry.bookingId(), + Classes.CHECKED_IN.equals(entry.state()), on -> mark(session, entry, on)); + Container row = MembersForm.person(entry.name(), detail.toString(), present); + row.setName("roster-" + iter); + list.add(row); + } + if (queued > 0) { + list.add(Ui.text(Lang.tr("{0} waiting for a place", Integer.valueOf(queued)), + "SvMuted")); + } + if (roster == null || roster.isEmpty()) { + list.add(Ui.text("Nobody booked yet.", "SvMuted")); + } + Ui.refresh(form); + } + + private static void mark(SessionDto session, RosterEntryDto entry, + final boolean present) { + Api.trainer().attendance(session.id(), new AttendanceDto(entry.bookingId(), present), + Net.to(saved -> Ui.say(present ? Lang.tr("Marked present") + : Lang.tr("Marked absent")), Ui::fail)); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/ShiftsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/ShiftsForm.java new file mode 100644 index 00000000000..b3abb9a7f95 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/ShiftsForm.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.ShiftDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Ui; +import java.util.List; + +/// The trainer's shifts still to come, and those colleagues offered up. A +/// shift of one's own can be offered for a swap (and the offer withdrawn); +/// someone else's offered shift can be taken. +public final class ShiftsForm { + /// The shift states the server uses. + public static final String ASSIGNED = "ASSIGNED"; + public static final String SWAP_REQUESTED = "SWAP_REQUESTED"; + + private ShiftsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Shifts", "Shifts"); + Ui.back(form, previous); + final Container list = Ui.list("shifts"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.trainer().shifts(Net.to(shifts -> fill(form, list, shifts, load[0]), + Ui.failed(list, load[0]))); + Ui.pull(list, load[0]); + form.add(BorderLayout.CENTER, list); + form.show(); + load[0].run(); + } + + private static void fill(Form form, Container list, List shifts, + final Runnable load) { + list.removeAll(); + String me = Session.user() == null ? "" : Session.user().username(); + Ui.section(list, "Mine"); + int mine = 0; + for (int iter = 0; shifts != null && iter < shifts.size(); iter++) { + final ShiftDto shift = shifts.get(iter); + if (!me.equals(shift.staff())) { + continue; + } + boolean offered = SWAP_REQUESTED.equals(shift.state()); + // The action sits on the row it is about rather than under it. + Button offer = action(offered ? "Withdraw" : "Offer swap", "offer-" + iter, + offered, e -> Api.trainer().offer(shift.id(), Net.to(changed -> { + Ui.say(SWAP_REQUESTED.equals(changed.state()) + ? Lang.tr("Offered to the team") : Lang.tr("Offer withdrawn")); + load.run(); + }, Ui::fail))); + list.add(Ui.row(FontImage.MATERIAL_SCHEDULE, shift.duty(), span(shift) + + (offered ? Ui.DOT + Lang.tr("Offered") : ""), offer)); + mine++; + } + if (mine == 0) { + list.add(Ui.text("No shifts to come.", "SvMuted")); + } + Ui.section(list, "Offered by others"); + int open = 0; + for (int iter = 0; shifts != null && iter < shifts.size(); iter++) { + final ShiftDto shift = shifts.get(iter); + if (me.equals(shift.staff()) || !SWAP_REQUESTED.equals(shift.state())) { + continue; + } + list.add(row(shift, action("Take", "take-" + iter, false, + e -> Api.trainer().take(shift.id(), Net.to(taken -> { + Ui.say(Lang.tr("The shift is yours")); + load.run(); + }, Ui::fail))))); + open++; + } + if (open == 0) { + list.add(Ui.text("Nothing offered right now.", "SvMuted")); + } + Ui.refresh(form); + } + + /// A shift as a row of a list, with who works it and `value` at its end. + public static Container row(ShiftDto shift, com.codename1.ui.Component value) { + return Ui.row(FontImage.MATERIAL_SCHEDULE, shift.duty(), span(shift) + Ui.DOT + + shift.staffName(), value); + } + + /// "06:00 - 12:00", for a list already headed by the day. + public static String hours(ShiftDto shift) { + String span = span(shift); + return span.substring(span.indexOf(", ") + 2); + } + + /// "Nov 2, 06:00 - 12:00", naming the end's day only when it is another. + static String span(ShiftDto shift) { + String start = Ui.when(shift.startsAt()); + String end = Ui.when(shift.endsAt()); + String day = start.substring(0, start.lastIndexOf(' ')); + return end.startsWith(day + " ") ? start + " - " + end.substring(day.length() + 1) + : start + " - " + end; + } + + private static Button action(String text, String name, boolean quiet, + ActionListener onClick) { + Button button = new Button(text, quiet ? "SvRowActionQuiet" : "SvRowAction"); + button.setName(name); + button.addActionListener(onClick); + return button; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/TrainerForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/TrainerForm.java new file mode 100644 index 00000000000..b5b44c8c726 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/trainer/TrainerForm.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.trainer; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.NameCountDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.TrainerStatsDto; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.member.Classes; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.owner.OwnerForm; +import com.codenameone.examples.pulse.owner.MembersForm; +import com.codenameone.examples.pulse.ui.Badges; +import com.codenameone.examples.pulse.ui.Beat; +import com.codenameone.examples.pulse.ui.Chart; +import com.codenameone.examples.pulse.ui.StatRow; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Level; +import com.codenameone.examples.pulse.ui.Menu; +import com.codenameone.examples.pulse.ui.Nav; +import com.codenameone.examples.pulse.ui.Ui; +import com.codenameone.examples.pulse.watch.WatchBridge; +import java.util.List; + +/// The trainer's home: the classes they teach this week, each leading to its +/// roster, and from there to the live view of the class. +public class TrainerForm extends LiveChannel.Adapter implements Nav.Home { + private final Form form = Ui.home("Trainer"); + private final Menu menu = new Menu(form, Nav.TRAINER); + private final Container list = Ui.list("trainer"); + private List classes; + private TrainerStatsDto stats; + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + menu.item(FontImage.MATERIAL_EVENT, "My classes", "classes", () -> form.showBack()); + menu.item(FontImage.MATERIAL_FAVORITE, "Live class", "live", this::live); + menu.item(FontImage.MATERIAL_SCHEDULE, "Shifts", "shifts", () -> ShiftsForm.show(form)); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, menu.flatButton()); + bar.add(BorderLayout.CENTER, Ui.label("Coaching", "SvTitle")); + Ui.pull(list, this::load); + Container shell = new Container(new BorderLayout()); + shell.add(BorderLayout.NORTH, bar); + shell.add(BorderLayout.CENTER, list); + form.add(shell); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + load(); + }); + } + + private void load() { + Api.trainer().classes(Net.to(found -> { + classes = found; + fill(); + }, Ui.failed(list, this::load))); + // The month is extra: when it does not come, the classes still do. + Api.trainer().stats(Net.to(found -> { + stats = found; + if (classes != null) { + fill(); + } + }, (status, message) -> { })); + } + + private void fill() { + list.removeAll(); + long at = System.currentTimeMillis(); + SessionDto featured = current(classes, at); + if (featured != null) { + list.add(featured(featured, featured.startsAt() <= at)); + } + WatchBridge.coach(featured, stats); + // Above the week rather than under it: below twenty class cards nobody found it. + if (stats != null) { + game(stats); + month(stats); + } + // The rest of the week as the members see it, a day at a time, with + // the room's fill instead of a booking button. + String day = null; + for (int iter = 0; classes != null && iter < classes.size(); iter++) { + final SessionDto session = classes.get(iter); + if (session == featured || session.endsAt() < at) { + continue; + } + String heading = dayName(session.startsAt(), at); + if (!heading.equals(day)) { + day = heading; + Ui.section(list, heading); + } + boolean cancelled = Classes.CANCELLED.equals(session.state()); + Label fill = Ui.badge(cancelled ? Lang.tr("Cancelled") + : session.booked() + "/" + session.capacity(), + cancelled ? "SvBadgeBad" : "SvBadge"); + list.add(Ui.tap(Classes.card(session, fill, null), "class-" + session.id(), + e -> RosterForm.show(form, session))); + } + if (classes == null || classes.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_SPORTS, Lang.tr("No classes this week"), + Lang.tr("Classes the studio gives you appear here."))); + } + Ui.refresh(form); + } + + /// The coach's game: their level and streak, their badges, and where they + /// stand among the studio's coaches. Members play for visits; a coach plays + /// for full rooms and people who keep coming back. + private void game(TrainerStatsDto game) { + Ui.section(list, "Your game"); + int span = Math.max(1, game.nextLevel() - game.levelFloor()); + Level ring = new Level(game.level(), Math.min(1.0, + (game.points() - game.levelFloor()) / (double) span)); + ring.setName("coachLevel"); + Container about = new Container(BoxLayout.y(), "SvLevelBox"); + about.add(Ui.label(Lang.tr("{0} points", Integer.valueOf(game.points())), "SvHeading")); + about.add(Ui.label(Lang.tr("{0} more to level {1}", + Integer.valueOf(game.nextLevel() - game.points()), + Integer.valueOf(game.level() + 1)), "SvMuted")); + Container chips = new Container(new FlowLayout()); + if (game.weekStreak() > 0) { + Label streak = Ui.badge(Lang.tr("{0} week streak", + Integer.valueOf(game.weekStreak())), "SvBadgeGood"); + Ui.icon(streak, FontImage.MATERIAL_LOCAL_FIRE_DEPARTMENT, 2.6f); + streak.getAllStyles().setMarginLeft(0); + chips.add(streak); + } + if (game.rank() > 0) { + Label rank = Ui.badge(Lang.tr("Rank {0} of {1}", Integer.valueOf(game.rank()), + Integer.valueOf(game.leaderboard().size())), + game.rank() == 1 ? "SvBadgeWarn" : "SvBadge"); + Ui.icon(rank, FontImage.MATERIAL_EMOJI_EVENTS, 2.6f); + chips.add(rank); + } + about.add(chips); + Container row = new Container(new BorderLayout()); + row.add(BorderLayout.WEST, ring).add(BorderLayout.CENTER, about); + list.add(row); + list.add(Ui.text(Lang.tr("10 points a check-in, 5 a kudos and 25 a full class."), + "SvMuted")); + + list.add(Ui.label(Lang.tr("Badges {0}/{1}", + Integer.valueOf(Badges.coachCount(game.badges())), + Integer.valueOf(Badges.coachTotal())), "SvChartTitle")); + list.add(Badges.coachShelf(game.badges())); + + List board = game.leaderboard(); + if (board != null && board.size() > 1) { + list.add(Ui.label("Leaderboard", "SvChartTitle")); + Container ranks = new Container(BoxLayout.y()); + ranks.setName("leaderboard"); + for (int iter = 0; iter < board.size(); iter++) { + NameCountDto coach = board.get(iter); + boolean me = iter + 1 == game.rank(); + Container line = MembersForm.line(coach.name(), Lang.tr("{0} check-ins", + Integer.valueOf(coach.count())), me ? Ui.badge(Lang.tr("You"), + "SvBadgeGood") : null); + line.add(BorderLayout.WEST, BorderLayout.centerAbsolute( + Ui.label(String.valueOf(iter + 1), iter == 0 ? "SvRankFirst" + : "SvRankPlace")).add(BorderLayout.EAST, + FlowLayout.encloseCenterMiddle(Ui.avatar(coach.name())))); + ranks.add(line); + } + list.add(ranks); + } + } + + /// The trainer's last four weeks: how many they taught and coached, how + /// full and how faithfully attended their classes were, and what for. + private void month(TrainerStatsDto month) { + Ui.section(list, "Your month"); + StatRow tiles = Ui.stats(3); + tiles.stat(String.valueOf(month.classes()), "Classes", "monthClasses") + .stat(String.valueOf(month.attendees()), "Check-ins", "monthAttendees") + .stat(month.fillPercent() + "%", "Places filled", "monthFill") + .stat(month.showPercent() + "%", "Showed up", "monthShow") + .stat(String.valueOf(month.regulars()), "Regulars", "monthRegulars") + .stat(String.valueOf(month.kudos()), "Kudos", "monthKudos"); + list.add(tiles); + List weeks = month.attendeesByWeek(); + double[] values = new double[weeks == null ? 0 : weeks.size()]; + for (int iter = 0; iter < values.length; iter++) { + Integer value = weeks.get(iter); + values[iter] = value == null ? 0 : value.intValue(); + } + list.add(Ui.label("Check-ins a week", "SvChartTitle")); + list.add(Chart.of("trainerAttendance", values, Lang.tr("7 weeks ago"), + Lang.tr("This week"))); + if (month.topClasses() != null && !month.topClasses().isEmpty()) { + list.add(Ui.label("Top classes", "SvChartTitle")); + list.add(OwnerForm.popular(month.topClasses())); + } + } + + // The class on now, or the next one: the one thing a coach opens the app + // for, so it leads on the dark card the member's home uses for theirs. + private Container featured(final SessionDto session, boolean running) { + Container card = new Container(BoxLayout.y(), "SvInk"); + card.setName("featured"); + Container head = new Container(new BorderLayout(), "SvInkRow"); + head.add(BorderLayout.CENTER, Ui.label(running ? "ON NOW" : "UP NEXT", "SvInkEyebrow")); + Beat beat = new Beat("SvBeat", running); + beat.setPreferredW(CN.convertToPixels(14)); + beat.setPreferredH(CN.convertToPixels(5)); + head.add(BorderLayout.EAST, beat); + card.add(head); + card.add(Ui.text(session.name(), "SvInkTitle")); + card.add(Ui.text(Ui.when(session.startsAt()) + Ui.DOT + session.roomName() + Ui.DOT + + Lang.tr("{0} of {1} booked", Integer.valueOf(session.booked()), + Integer.valueOf(session.capacity())), "SvInkText")); + Button live = new Button(Lang.tr("Open live view"), "SvInkButton"); + live.setName("openLive"); + live.addActionListener(e -> LiveClassForm.show(form, session)); + Button roster = new Button(Lang.tr("Roster"), "SvInkText"); + roster.getAllStyles().setBgTransparency(0); + // Named as a class in the list below is, since both open its roster: + // the class leaves the list while it is the one featured here. + roster.setName("class-" + session.id()); + roster.addActionListener(e -> RosterForm.show(form, session)); + Container actions = new Container(new BorderLayout(), "SvInkRow"); + actions.add(BorderLayout.WEST, live).add(BorderLayout.EAST, roster); + card.add(actions); + return card; + } + + private static String dayName(long millis, long now) { + java.util.Calendar a = java.util.Calendar.getInstance(); + a.setTime(new java.util.Date(millis)); + java.util.Calendar b = java.util.Calendar.getInstance(); + b.setTime(new java.util.Date(now)); + int days = (int) ((start(a) - start(b)) / 86400000L); + if (days == 0) { + return Lang.tr("Today"); + } + if (days == 1) { + return Lang.tr("Tomorrow"); + } + return new com.codename1.l10n.SimpleDateFormat("EEEE, MMM d").format( + new java.util.Date(millis)); + } + + private static long start(java.util.Calendar day) { + day.set(java.util.Calendar.HOUR_OF_DAY, 12); + day.set(java.util.Calendar.MINUTE, 0); + day.set(java.util.Calendar.SECOND, 0); + day.set(java.util.Calendar.MILLISECOND, 0); + return day.getTime().getTime(); + } + + /// Opens the class that is on now, or the next one. + private void live() { + SessionDto now = current(classes, System.currentTimeMillis()); + if (now == null) { + Ui.say(Lang.tr("No class today")); + return; + } + LiveClassForm.show(form, now); + } + + /// The class running at `at`, else the next to start; cancelled ones never. + static SessionDto current(List classes, long at) { + SessionDto next = null; + for (int iter = 0; classes != null && iter < classes.size(); iter++) { + SessionDto session = classes.get(iter); + if (Classes.CANCELLED.equals(session.state()) || session.endsAt() < at) { + continue; + } + if (session.startsAt() <= at) { + return session; + } + if (next == null || session.startsAt() < next.startsAt()) { + next = session; + } + } + return next; + } + + @Override + public void sessionChanged(String id, int booked, int waitlisted, String state) { + load(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Badges.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Badges.java new file mode 100644 index 00000000000..9e863cd62dc --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Badges.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.plaf.RoundBorder; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.Sheet; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; + +import java.util.List; + +/// The badges a member can earn, as the app shows them. The server decides +/// which are earned (`Points` in the engagement package); this only says what +/// each looks like and how it is earned, so a badge the server adds later +/// shows with a generic medal until this learns about it. +public final class Badges { + /// Every badge, in the server's order, with its icon, name and how it is + /// earned. + static final String[] IDS = {"FIRST_CLASS", "TEN_CLASSES", "FIFTY_CLASSES", "STREAK_4", + "STREAK_12", "HEART_TRACKED", "STRAPPED_IN", "ZONE_5", "TEN_HOURS"}; + static final char[] ICONS = {FontImage.MATERIAL_FLAG, FontImage.MATERIAL_LOOKS_ONE, + FontImage.MATERIAL_MILITARY_TECH, FontImage.MATERIAL_LOCAL_FIRE_DEPARTMENT, + FontImage.MATERIAL_WHATSHOT, FontImage.MATERIAL_FAVORITE, + FontImage.MATERIAL_MONITOR_HEART, FontImage.MATERIAL_BOLT, + FontImage.MATERIAL_TIMER}; + static final String[] NAMES = {"First class", "Ten classes", "Fifty classes", + "Four-week streak", "Twelve-week streak", "Heart tracked", "Strapped in", "Zone 5", + "Ten hours"}; + static final String[] HOW = {"Come to your first class.", "Come to ten classes.", + "Come to fifty classes.", "Train every week for four weeks running.", + "Train every week for twelve weeks running.", + "Record a workout with your heart rate.", + "Wear a heart-rate strap or a watch in a class.", + "Spend a minute flat out, in zone 5.", "Record ten hours of workouts."}; + + /// The coach's badges, as `Trainers.stats` on the server earns them. + static final String[] COACH_IDS = {"FULL_HOUSE", "FIVE_FULL", "TEN_REGULARS", "KUDOS_25", + "SHOW_90", "STREAK_8", "HUNDRED", "TOP_COACH"}; + static final char[] COACH_ICONS = {FontImage.MATERIAL_EVENT_SEAT, + FontImage.MATERIAL_STADIUM, FontImage.MATERIAL_DIVERSITY_3, + FontImage.MATERIAL_THUMB_UP, FontImage.MATERIAL_VERIFIED, + FontImage.MATERIAL_LOCAL_FIRE_DEPARTMENT, FontImage.MATERIAL_GROUPS, + FontImage.MATERIAL_EMOJI_EVENTS}; + static final String[] COACH_NAMES = {"Full house", "Packed month", "Ten regulars", + "Crowd favourite", "No empty mats", "Eight-week run", "Hundred club", "Top coach"}; + static final String[] COACH_HOW = {"Fill every place in a class.", + "Fill five classes in four weeks.", + "Have ten members come to three or more of your classes in four weeks.", + "Collect 25 kudos on workouts from your classes in four weeks.", + "Have nine in ten booked members show up, over twenty or more check-ins.", + "Teach every week for eight weeks running.", + "Coach a hundred check-ins in eight weeks.", + "Coach more people than anyone in the studio over four weeks."}; + + private Badges() { + } + + /// A row of every badge, earned ones in colour and the rest greyed out, + /// each opening a sheet that says how it is earned. + public static Container shelf(List earned) { + return shelf(earned, IDS, ICONS, NAMES, HOW, "badges"); + } + + /// The coach's shelf: the same medals, for the coach's own badges. + public static Container coachShelf(List earned) { + return shelf(earned, COACH_IDS, COACH_ICONS, COACH_NAMES, COACH_HOW, "coachBadges"); + } + + /// How many coach badges there are to earn. + public static int coachTotal() { + return COACH_IDS.length; + } + + /// How many of the coach badges are earned. + public static int coachCount(List earned) { + return count(earned, COACH_IDS); + } + + private static Container shelf(List earned, String[] ids, char[] icons, + String[] names, String[] how, String name) { + Container row = new Container(BoxLayout.x()); + row.setUIID("SvShelf"); + row.setScrollableX(true); + row.setScrollVisible(false); + row.setName(name); + for (int iter = 0; iter < ids.length; iter++) { + row.add(medal(ids[iter], icons[iter], names[iter], how[iter], + earned != null && earned.contains(ids[iter]))); + } + return row; + } + + /// How many badges there are to earn. + public static int total() { + return IDS.length; + } + + /// How many of the badges are earned. + public static int count(List earned) { + return count(earned, IDS); + } + + private static int count(List earned, String[] ids) { + int count = 0; + for (int iter = 0; earned != null && iter < ids.length; iter++) { + if (earned.contains(ids[iter])) { + count++; + } + } + return count; + } + + /// Whether a badge is earned. + public static boolean has(List earned, String id) { + return earned != null && earned.contains(id); + } + + private static Container medal(String id, final char icon, final String key, + final String how, final boolean earned) { + Button disc = new Button("", earned ? "SvMedal" : "SvMedalLocked"); + Ui.icon(disc, earned ? icon : FontImage.MATERIAL_LOCK, 4.4f); + disc.setName("badge-" + id); + disc.getAllStyles().setAlignment(Label.CENTER); + round(disc); + String name = Lang.tr(key); + disc.setAccessibilityText(earned ? name : Lang.tr("{0}, not earned yet", name)); + disc.addActionListener(e -> explain(icon, key, how, earned)); + Container cell = new Container(new BoxLayout(BoxLayout.Y_AXIS)); + cell.setUIID("SvMedalCell"); + cell.add(FlowLayout.encloseCenter(disc)); + Label caption = new Label(name, earned ? "SvMedalName" : "SvMedalNameLocked"); + caption.getAllStyles().setAlignment(Label.CENTER); + cell.add(caption); + return cell; + } + + // A circle in the style's own background colour, whatever the icon's size. + private static void round(Component disc) { + disc.getAllStyles().setBorder(RoundBorder.create() + .color(disc.getUnselectedStyle().getBgColor()).rectangle(false)); + } + + private static void explain(char icon, String key, String how, boolean earned) { + Container body = new Container(BoxLayout.y()); + Label disc = new Label("", earned ? "SvMedal" : "SvMedalLocked"); + Ui.icon(disc, icon, 6f); + round(disc); + disc.getAllStyles().setAlignment(Label.CENTER); + body.add(FlowLayout.encloseCenter(disc)); + Label title = new Label(Lang.tr(key), "SvHeading"); + title.getAllStyles().setAlignment(Label.CENTER); + body.add(title); + SpanLabel text = new SpanLabel(Lang.tr(how), "SvMuted"); + text.setTextBlockAlign(Label.CENTER); + body.add(text); + body.add(FlowLayout.encloseCenter(Ui.badge(earned ? Lang.tr("Earned") + : Lang.tr("Not earned yet"), earned ? "SvBadgeGood" : "SvBadge"))); + Sheet sheet = new Sheet(null, ""); + sheet.getContentPane().setLayout(new BorderLayout()); + sheet.getContentPane().add(BorderLayout.CENTER, body); + sheet.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Beat.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Beat.java new file mode 100644 index 00000000000..6536db0620e --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Beat.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.plaf.Style; + +/// A heartbeat trace drawn across the component, the app's signature: flat, +/// a spike, flat again. It is drawn, not an image, so it is sharp at any +/// density and takes its colour from the style's foreground. When `running` +/// the spike travels along the line, the way a monitor's trace moves. +public final class Beat extends Component { + private final boolean running; + private long started; + + /// @param uiid the style whose foreground colour the trace takes + public Beat(String uiid, boolean running) { + setUIID(uiid); + this.running = running; + setFocusable(false); + } + + @Override + protected void initComponent() { + super.initComponent(); + started = System.currentTimeMillis(); + if (running && getComponentForm() != null) { + getComponentForm().registerAnimated(this); + } + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + return running; + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(40), CN.convertToPixels(9)); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int width = getWidth() - style.getHorizontalPadding(); + int top = getY() + style.getPaddingTop(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + int mid = top + height / 2; + // Where the beat is, as a share of the width: one sweep every three + // seconds when running, a third of the way along when still. + double at = running ? ((System.currentTimeMillis() - started) % 3000L) / 3000d : 0.32d; + int unit = Math.max(2, height / 6); + // The spike travels over the width less its own, so it is never cut + // off by the right edge into a flat line with a tick. + int x0 = left + (int) (Math.max(0, width - 6 * unit) * at); + int[] xs = {left, x0, x0 + unit, x0 + 2 * unit, x0 + 3 * unit, x0 + 4 * unit, + x0 + 5 * unit, x0 + 6 * unit, left + width}; + int[] ys = {mid, mid, mid - height / 6, mid + height / 5, mid - height / 2 + 1, + mid + height / 2 - 1, mid - height / 8, mid, mid}; + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + g.setAlpha(255); + float thickness = Math.max(2f, CN.convertToPixels(0.6f)); + if (g.isShapeSupported()) { + GeneralPath path = new GeneralPath(); + path.moveTo(xs[0], ys[0]); + for (int iter = 1; iter < xs.length; iter++) { + path.lineTo(xs[iter], ys[iter]); + } + g.drawShape(path, new Stroke(thickness, Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 1f)); + } else { + for (int iter = 1; iter < xs.length; iter++) { + g.drawLine(xs[iter - 1], ys[iter - 1], xs[iter], ys[iter]); + } + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Chart.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Chart.java new file mode 100644 index 00000000000..8c1e1b7db01 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Chart.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A row of bars: visits a week, takings a day, check-ins by the hour. +/// +/// Drawn here, in a few lines, because that is all a bar chart is and because +/// it then takes its colours from `theme.css` like everything else: the bars +/// are the `SvChart` style's colour, the highest of them `SvChartPeak`'s, and +/// the lines behind them `SvChartGrid`'s. Nothing here names a colour. +public final class Chart extends Component { + private static final float HEIGHT_MM = 24f; + + private final double[] values; + + private Chart(double[] values) { + this.values = values; + setUIID("SvChart"); + setFocusable(false); + } + + /// The chart, with what its two ends are written under it. + /// + /// @param name what a test finds the chart by + /// @param from the caption under the first bar, already translated + /// @param to the caption under the last + public static Container of(String name, double[] values, String from, String to) { + Chart chart = new Chart(values); + chart.setName(name); + Container box = new Container(new BorderLayout()); + box.add(BorderLayout.CENTER, chart); + Container axis = new Container(new BorderLayout()); + axis.add(BorderLayout.WEST, Ui.plain(from, "SvChartAxis")); + axis.add(BorderLayout.EAST, Ui.plain(to, "SvChartAxis")); + box.add(BorderLayout.SOUTH, axis); + return box; + } + + /// How many bars there are, for a test. + public int bars() { + return values.length; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f), + CN.convertToPixels(HEIGHT_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + UIManager styles = getUIManager(); + g.setColor(styles.getComponentStyle("SvChartGrid").getFgColor()); + // Three lines to read the heights against: the foot, half, the top. + for (int line = 0; line <= 2; line++) { + int y = top + (height - 1) * line / 2; + g.drawLine(left, y, left + width, y); + } + int count = values.length; + if (count == 0) { + return; + } + double most = 0; + int peak = -1; + for (int iter = 0; iter < count; iter++) { + if (values[iter] >= most && values[iter] > 0) { + most = values[iter]; + peak = iter; + } + } + if (most <= 0) { + return; + } + // The space is shared out in whole pixels, so every bar is as wide as + // every other and what is left over goes to the two ends. + int slot = Math.max(1, width / count); + int gap = Math.max(1, slot / 4); + int bar = Math.max(1, slot - gap); + int start = left + (width - slot * count + gap) / 2; + int round = Math.min(bar, CN.convertToPixels(1.2f)); + int normal = style.getFgColor(); + int highest = styles.getComponentStyle("SvChartPeak").getFgColor(); + boolean rtl = isRTL(); + g.setAntiAliased(true); + for (int iter = 0; iter < count; iter++) { + int tall = (int) Math.round(values[iter] / most * (height - 2)); + if (values[iter] > 0 && tall < round) { + tall = round; + } + if (tall <= 0) { + continue; + } + // Time runs the way the language is read. + int at = rtl ? count - 1 - iter : iter; + g.setColor(iter == peak ? highest : normal); + g.fillRoundRect(start + at * slot, top + height - tall, bar, tall, round, round); + } + } + + /// A share of something, as a bar part of the way along a track: the bar + /// is the `SvMeter` style's colour and the track a faint wash of it. + public static final class Meter extends Component { + private static final int TRACK_ALPHA = 36; + private final double share; + + /// @param share from 0 to 1 + public Meter(double share) { + this.share = share < 0 ? 0 : share > 1 ? 1 : share; + setUIID("SvMeter"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(20f), + CN.convertToPixels(1.6f) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + g.setAntiAliased(true); + // The track is the bar's own colour, faint: a background colour + // would be painted square behind the rounded ends. + int alpha = g.getAlpha(); + g.setColor(style.getFgColor()); + g.setAlpha(TRACK_ALPHA); + g.fillRoundRect(left, top, width, height, height, height); + g.setAlpha(alpha); + int filled = (int) Math.round(width * share); + if (filled > 0) { + filled = Math.max(filled, height); + g.setColor(style.getFgColor()); + g.fillRoundRect(isRTL() ? left + width - filled : left, top, filled, height, + height, height); + } + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ConsentForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ConsentForm.java new file mode 100644 index 00000000000..f8e1c994dd4 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ConsentForm.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.pulse.Telemetry; + +/// Asks, once on a device, whether the app may report how it is used. +/// +/// It is shown after the first sign-in and before anything is counted, and it +/// has no way out but an answer: nothing is reported while there is none, so +/// leaving the question open would be a no that is asked again at every +/// start. Either answer can be changed in Settings, which is said here. +public final class ConsentForm { + private ConsentForm() { + } + + /// @param then where to go once there is an answer + public static void show(final Runnable then) { + Form form = Ui.form("Usage statistics", "Consent"); + Container page = Ui.page(); + Container banner = Ui.banner(FontImage.MATERIAL_INSIGHTS, "Help improve Pulse", + "Pulse can count which screens are opened and which features are used, " + + "so that we can see what works and fix what does not.", "SvBannerIcon"); + // The page has its own top padding, and the banner's on top of it + // pushed the last line of the promise below under the answers. + banner.getAllStyles().setPaddingTop(0); + page.add(banner); + page.add(Ui.row(FontImage.MATERIAL_VISIBILITY_OFF, "No personal details", + "Never your name, e-mail, phone number, places or card details", null)); + // The two answers stay in view however much there is to read. + Container answers = new Container(BoxLayout.y()); + answers.setUIID("SvPage"); + answers.setSafeArea(true); + answers.add(Ui.primary("Share usage statistics", "consentYes", e -> answer(true, then))); + answers.add(Ui.secondary("Not now", "consentNo", e -> answer(false, then))); + answers.add(Ui.text("Turn it on or off at any time in Settings", "SvMuted")); + form.add(BorderLayout.SOUTH, answers); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static void answer(boolean yes, Runnable then) { + Telemetry.allow(yes); + then.run(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/FactRow.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/FactRow.java new file mode 100644 index 00000000000..a9c5ecddeaf --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/FactRow.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.Style; + +/// One line of a record: what it is, and its value at the far side. +/// +/// A row of two labels in a `BorderLayout` gives the value all the width it +/// asks for and the caption what is left, so a long value cut the caption to +/// "Legal nar" or "Veh". This row puts the two side by side while they fit, +/// and otherwise the caption on a line of its own with the value under it. +final class FactRow extends Container { + private final Label caption; + private final Label value; + /// Whether the height last reported was the stacked one. + private boolean plannedStacked; + + FactRow(Label caption, Label value) { + this.caption = caption; + this.value = value; + value.setEndsWith3Points(true); + setLayout(new Rows()); + add(caption); + add(value); + } + + private int inner(int width) { + Style s = getStyle(); + return width - s.getHorizontalPadding(); + } + + private static int outerWidth(Component c) { + return c.getPreferredW() + c.getStyle().getHorizontalMargins(); + } + + private static int outerHeight(Component c) { + return c.getPreferredH() + c.getStyle().getVerticalMargins(); + } + + private boolean stacked(int room) { + // A gap the width of a space or two between the caption and its value. + return outerWidth(caption) + CN.convertToPixels(3) + outerWidth(value) > room; + } + + private int roomOffered() { + if (getWidth() > 0) { + return inner(getWidth()); + } + Container parent = getParent(); + while (parent != null) { + if (parent.getWidth() > 0) { + return inner(parent.getWidth() - parent.getStyle().getHorizontalPadding()); + } + parent = parent.getParent(); + } + return inner(CN.getDisplayWidth()); + } + + private final class Rows extends Layout { + @Override + public void layoutContainer(Container parent) { + Style s = getStyle(); + int left = s.getPaddingLeftNoRTL(); + int top = s.getPaddingTop(); + int room = inner(getWidth()); + boolean stack = stacked(room); + boolean rtl = isRTL(); + int cw = Math.min(room, outerWidth(caption)); + int vw = Math.min(room, outerWidth(value)); + int ch = outerHeight(caption); + int vh = outerHeight(value); + Style cs = caption.getStyle(); + Style vs = value.getStyle(); + if (stack) { + place(caption, rtl ? left + room - cw : left, top, cw, ch, cs); + place(value, rtl ? left : left + room - vw, top + ch, vw, vh, vs); + } else { + int h = Math.max(ch, vh); + place(caption, rtl ? left + room - cw : left, top + (h - ch) / 2, cw, ch, cs); + place(value, rtl ? left : left + room - vw, top + (h - vh) / 2, vw, vh, vs); + } + if (stack != plannedStacked) { + // The height was answered for another width. + plannedStacked = stack; + CN.callSerially(() -> { + setShouldCalcPreferredSize(true); + Form form = getComponentForm(); + if (form != null) { + form.revalidate(); + } + }); + } + } + + private void place(Component c, int x, int y, int w, int h, Style st) { + c.setX(x + st.getMarginLeftNoRTL()); + c.setY(y + st.getMarginTop()); + c.setWidth(w - st.getHorizontalMargins()); + c.setHeight(h - st.getVerticalMargins()); + } + + @Override + public Dimension getPreferredSize(Container parent) { + Style s = getStyle(); + plannedStacked = stacked(roomOffered()); + int ch = outerHeight(caption); + int vh = outerHeight(value); + int h = plannedStacked ? ch + vh : Math.max(ch, vh); + int w = plannedStacked ? Math.max(outerWidth(caption), outerWidth(value)) + : outerWidth(caption) + CN.convertToPixels(3) + outerWidth(value); + return new Dimension(w + s.getHorizontalPadding(), h + s.getVerticalPadding()); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Lang.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Lang.java new file mode 100644 index 00000000000..9031002e8b9 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Lang.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.l10n.L10NManager; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codenameone.examples.pulse.Prefs; +import java.util.HashMap; +import java.util.Map; + +/// The language the app speaks. +/// +/// The translations are the `Bundle_.properties` files in +/// `src/main/l10n`, which the build packs into the theme. One of them at a +/// time is installed as the `UIManager`'s bundle, and from then on every +/// label, button, title and hint looks its text up there as it is set: the +/// English text in the source is the key, and a text with no entry is shown as +/// it is. So a screen written in English is translated without saying so. +/// +/// What a component cannot do by itself is a sentence with something put into +/// it -- "Pick up Riley". Those go through [#tr(String, Object...)], with the +/// whole sentence as the key and `{0}` where the name goes, because where it +/// goes is the translator's decision. +/// +/// To add a string: write it in English in the code, and add a line for it to +/// each of the four translated files. To add a language: add its file, and its +/// code to [#CODES]. +public final class Lang { + /// The languages there is a translation for. English is the source. + public static final String[] CODES = {"en", "es", "fr", "de", "he"}; + + private static final String BUNDLE = "Bundle"; + /// The entry each file names its own language in, in that language. + private static final String NAME_KEY = "language.name"; + + private Lang() { + } + + /// The language in use: the one chosen in Settings, else the device's when + /// there is a translation for it, else English. + public static String current() { + String chosen = known(Prefs.language()); + if (chosen != null) { + return chosen; + } + String device = known(L10NManager.getInstance().getLanguage()); + return device == null ? CODES[0] : device; + } + + /// Installs the translation for [#current()]. A screen built after this is + /// in the new language, and laid out right to left when the language is + /// written that way; one already built keeps the words it was built with. + public static void apply() { + Map bundle = new HashMap(); + // Always said, one way or the other: a bundle that is silent about the + // direction leaves the one before it in force. + bundle.put("@rtl", "false"); + Map translated = table(current()); + if (translated != null) { + bundle.putAll(translated); + } + UIManager.getInstance().setBundle(bundle); + } + + /// What `code`'s language calls itself. + public static String name(String code) { + Map translated = table(code); + String name = translated == null ? null : translated.get(NAME_KEY); + return name == null || name.length() == 0 ? code : name; + } + + public static String tr(String text) { + return UIManager.getInstance().localize(text, text); + } + + /// Translates `pattern` and puts `values` where it says `{0}`, `{1}`... + public static String tr(String pattern, Object... values) { + String text = tr(pattern); + for (int iter = 0; iter < values.length; iter++) { + String mark = "{" + iter + "}"; + String value = String.valueOf(values[iter]); + int at = text.indexOf(mark); + while (at >= 0) { + text = text.substring(0, at) + value + text.substring(at + mark.length()); + at = text.indexOf(mark, at + value.length()); + } + } + return text; + } + + /// The code among [#CODES] that `language` is, or null. A device reports + /// its language with or without a country (`es`, `es_MX`) and Hebrew under + /// either of two codes; this is compared as the ASCII it is, since folding + /// the case would follow the very language being worked out. + private static String known(String language) { + if (language == null || language.length() < 2) { + return null; + } + if (language.regionMatches(true, 0, "iw", 0, 2)) { + return "he"; + } + for (String code : CODES) { + if (language.regionMatches(true, 0, code, 0, 2)) { + return code; + } + } + return null; + } + + private static Map table(String code) { + Resources theme = Resources.getGlobalResources(); + return theme == null ? null : theme.getL10N(BUNDLE, code); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Layouts.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Layouts.java new file mode 100644 index 00000000000..d119f099dff --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Layouts.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import java.util.HashMap; +import java.util.Map; + +/// How much room there is, and whose look the app wears. +/// +/// The app is laid out for a phone. Two things change that, and both are +/// asked here so that no screen works them out for itself: +/// +/// - [#wide()]: the window is as wide as a tablet on its side, a desktop +/// window or a browser. The admin console then shows its navigation and a +/// list with its detail side by side; every other screen keeps its column +/// and stops it growing past [#column()]. +/// - [#desktopLook()]: the platform's own desktop theme is installed -- macOS +/// Aqua, Windows Fluent or GNOME Adwaita, chosen by the `nativeTheme=native` +/// build hint. The admin console then dresses its controls in that theme's +/// buttons, fields and lists with [#dress(Container)], and keeps the app's +/// own styles only for what a desktop has no control for: the charts and +/// the numbers. +/// +/// Both are read when a screen is built. A window that is resized across the +/// line, or a device that is turned, rebuilds the screen: see +/// [#onChange(Form, Runnable)]. +public final class Layouts { + /// A window at least this wide, in millimetres, is laid out side by side. + /// A phone on its side is narrower; a tablet on its side is not. + private static final float WIDE_MM = 165f; + /// The widest a column of text and controls is left to grow. + private static final float COLUMN_MM = 150f; + + /// The app's own style names, and the name each goes by under a desktop + /// theme. Those are declared at the end of `theme.css`, each deriving from + /// the native control it stands for. + private static final Map DESKTOP = new HashMap(); + + static { + String[] dressed = {"SvForm", "SvPage", "SvList", "SvPrimary", "SvSecondary", "SvDanger", + "SvLink", "SvField", "SvFieldHint", "SvRow", "SvRowPicked", "SvRowIcon", "SvRowTitle", + "SvRowDetail", "SvRowValue", "SvSection", "SvHeading", "SvText", "SvMuted", "SvLabel", + "SvTitle", "SvError", "SvNotice", "SvPanel", "SvFact", "SvFactLabel", "SvFactValue", + "SvFactTotal", "SvSegments", "SvSegment", "SvTile", "SvStat", "SvStatValue", + "SvStatLabel", "SvChartValue", "SvChartAxis", "SvAmount", "SvBannerIcon", + "SvBannerTitle", "SvBannerText", "SvAlert", "SvSide", "SvSideItem", "SvSidePicked", + "SvSideLabel", "SvSideName", "SvSideDetail", "SvPaneBar", "SvPaneTitle", "SvMaster", + "SvBarButton", "SvHomeBar"}; + for (int iter = 0; iter < dressed.length; iter++) { + DESKTOP.put(dressed[iter], "SvDesk" + dressed[iter].substring(2)); + } + } + + private Layouts() { + } + + /// Whether there is room to lay a screen out side by side. + public static boolean wide() { + return CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + } + + /// The client property that lets a page of figures grow to [#broad()]. + private static final String BROAD = "pulse.broad"; + /// The widest a page of figures side by side is left to grow. + private static final float BROAD_MM = 270f; + + /// Lets `page` grow to [#broad()] rather than [#column()]: for a page of + /// figures laid out side by side on a wide window, where a column of + /// reading width would leave most of the window empty. + public static void broaden(Container page) { + page.putClientProperty(BROAD, Boolean.TRUE); + } + + /// The widest a page of figures is drawn, in pixels. + public static int broad() { + return CN.convertToPixels(BROAD_MM); + } + + /// Two halves of `page` side by side on a wide window, the right one set + /// off from the left; on anything narrower both are `page` itself, so + /// what goes in the second simply follows what went in the first. + public static Container[] halves(Container page) { + if (!wide()) { + return new Container[] {page, page}; + } + Container left = new Container(BoxLayout.y()); + Container right = new Container(BoxLayout.y()); + right.setUIID("SvBroadRight"); + Container both = new Container(new GridLayout(1, 2)); + both.add(left).add(right); + page.add(both); + return new Container[] {left, right}; + } + + /// The widest a single column of content is drawn, in pixels. + public static int column() { + return CN.convertToPixels(COLUMN_MM); + } + + /// How many columns of `millimetres` each fit in `width` pixels: at least + /// one, and no more than `most`. + public static int columns(int width, float millimetres, int most) { + int fit = width / Math.max(1, CN.convertToPixels(millimetres)); + return Math.max(1, Math.min(most, fit)); + } + + /// Whether the platform's desktop theme is the one under the app's own. + /// + /// The three desktop themes are the only ones installed on a desktop that + /// mark themselves as a platform's defaults; a desktop build that kept the + /// legacy theme, a phone and a tablet all answer false and keep the app's + /// look everywhere. + public static boolean desktopLook() { + return CN.isDesktop() + && UIManager.getInstance().isThemeConstant("nativeThemeDefaultsBool", false); + } + + /// Dresses everything under `root` in the desktop theme, when there is one: + /// each component styled by the app takes the twin of its style that + /// derives from the platform's control, and whatever scrolls shows the + /// platform's scrollbar. Does nothing anywhere else. + /// + /// It is done to a finished tree rather than where each piece is made, so + /// that the screens are written once, for a phone, and the console alone + /// decides what a desktop makes of them. + public static void dress(Container root) { + if (!desktopLook()) { + return; + } + dressed(root); + } + + private static void dressed(Component component) { + String twin = DESKTOP.get(component.getUIID()); + if (twin != null) { + component.setUIID(twin); + if (component instanceof Label) { + redraw((Label) component); + } + } + if (component instanceof SpanLabel) { + SpanLabel span = (SpanLabel) component; + String text = DESKTOP.get(span.getTextUIID()); + if (text != null) { + span.setTextUIID(text); + } + return; + } + if (component instanceof Container) { + Container holder = (Container) component; + if (holder.isScrollableY()) { + holder.setScrollVisible(true); + } + // A row that is one button is still a container of labels. + for (int iter = 0; iter < holder.getComponentCount(); iter++) { + dressed(holder.getComponentAt(iter)); + } + } + } + + /// Draws the icon of `label` again in the colours of the style it now has: + /// an icon is an image, made once from the style the label had then. + private static void redraw(Label label) { + if (!(label.getIcon() instanceof FontImage)) { + return; + } + FontImage icon = (FontImage) label.getIcon(); + String glyph = icon.getText(); + float pixels = icon.getFont() == null ? 0 : icon.getFont().getPixelSize(); + if (glyph == null || glyph.length() != 1 || pixels <= 0) { + return; + } + FontImage.setMaterialIcon(label, glyph.charAt(0), + pixels * 100f / CN.convertToPixels(100f)); + } + + /// Keeps the content of `page` in a column no wider than [#column()], in + /// the middle of however much room there is: called by the page as it is + /// laid out, with the padding its style gave it. + /// + /// @param margin the side padding of the page's own style, in pixels + static void centre(Container page, int margin) { + int widest = Boolean.TRUE.equals(page.getClientProperty(BROAD)) ? broad() : column(); + int side = Math.max(margin, (page.getWidth() - widest) / 2); + Style style = page.getAllStyles(); + Style now = page.getUnselectedStyle(); + if (now.getPaddingLeftNoRTL() != side || now.getPaddingRightNoRTL() != side) { + style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); + style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); + style.setPaddingLeft(side); + style.setPaddingRight(side); + } + } + + /// Calls `rebuild` when `form` stops being wide or starts to be, which is + /// a window dragged across the line or a tablet turned. The screen was + /// built for the room there was; it is built again for the room there is. + public static void onChange(final Form form, final Runnable rebuild) { + final boolean[] was = {wide()}; + form.addSizeChangedListener(e -> { + boolean now = wide(); + if (now != was[0]) { + was[0] = now; + // After the event: the form is in the middle of being resized. + CN.callSerially(rebuild); + } + }); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Level.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Level.java new file mode 100644 index 00000000000..a4326c3eb03 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Level.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A ring that fills as a member's points climb toward the next level, with +/// the level's number in the middle. +/// +/// The filled part takes `SvLevel`'s foreground, the rest `SvLevelTrack`'s, +/// and the number `SvLevel`'s font. It fills from empty when it is first shown, +/// over most of a second, so earning points feels like something. +public final class Level extends Component { + private final int level; + private final double share; + private long shown; + + /// @param share how far from this level to the next, 0 to 1 + public Level(int level, double share) { + setUIID("SvLevel"); + this.level = level; + this.share = Math.max(0d, Math.min(1d, share)); + setFocusable(false); + } + + @Override + protected void initComponent() { + super.initComponent(); + shown = System.currentTimeMillis(); + if (getComponentForm() != null) { + getComponentForm().registerAnimated(this); + } + } + + @Override + protected void deinitialize() { + if (getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + // One last frame at the full share, then nothing more to draw. + if (System.currentTimeMillis() - shown > 900L && getComponentForm() != null) { + getComponentForm().deregisterAnimated(this); + } + return true; + } + + @Override + protected Dimension calcPreferredSize() { + int side = CN.convertToPixels(19); + return new Dimension(side, side); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int side = Math.min(getWidth() - style.getHorizontalPadding(), + getHeight() - style.getVerticalPadding()); + if (side <= 0) { + return; + } + int stroke = Math.max(3, CN.convertToPixels(1.6f)); + int size = side - stroke; + int x = getX() + (getWidth() - size) / 2; + int y = getY() + (getHeight() - size) / 2; + double t = Math.min(1d, (System.currentTimeMillis() - shown) / 900d); + double eased = 1 - (1 - t) * (1 - t) * (1 - t); + int sweep = (int) Math.round(360 * share * eased); + int track = UIManager.getInstance().getComponentStyle("SvLevelTrack").getFgColor(); + g.setAntiAliased(true); + if (g.isShapeSupported()) { + Stroke pen = new Stroke(stroke, Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 1f); + g.setColor(track); + g.drawShape(arc(x, y, size, 0, 360), pen); + if (sweep > 0) { + g.setColor(style.getFgColor()); + g.drawShape(arc(x, y, size, 90, -sweep), pen); + } + } else { + g.setColor(track); + g.fillArc(x - stroke / 2, y - stroke / 2, size + stroke, size + stroke, 0, 360); + g.setColor(style.getFgColor()); + g.fillArc(x - stroke / 2, y - stroke / 2, size + stroke, size + stroke, 90, -sweep); + g.setColor(style.getBgColor()); + g.fillArc(x + stroke / 2, y + stroke / 2, size - stroke, size - stroke, 0, 360); + } + String text = String.valueOf(level); + Font font = style.getFont(); + g.setFont(font); + // The number takes the page's text colour: lime text on a light page + // could not be read, and the track is dark in dark mode. + g.setColor(UIManager.getInstance().getComponentStyle("SvHeading").getFgColor()); + g.drawString(text, getX() + (getWidth() - font.stringWidth(text)) / 2, + getY() + (getHeight() - font.getHeight()) / 2); + } + + private static GeneralPath arc(int x, int y, int size, int start, int sweep) { + GeneralPath path = new GeneralPath(); + path.arc(x, y, size, size, Math.toRadians(start), Math.toRadians(sweep)); + return path; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Look.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Look.java new file mode 100644 index 00000000000..14617ebe1e0 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Look.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.pulse.Prefs; + +/// Light or dark. +/// +/// `theme.css` has both: every rule has its dark twin in the block at the end +/// of the file. Which of the two a component gets is decided when its style is +/// resolved, so a screen built after [#apply()] is in the new look and one +/// already built is not -- the caller rebuilds what is on screen. +public final class Look { + private Look() { + } + + /// Makes the look the one chosen in Settings: light, dark, or whichever + /// the device is in. + public static void apply() { + String theme = Prefs.theme(); + CN.setDarkMode(Prefs.THEME_DARK.equals(theme) ? Boolean.TRUE + : Prefs.THEME_LIGHT.equals(theme) ? Boolean.FALSE : null); + if (CN.isDesktop()) { + // On a desktop the platform's theme takes the colours and the font + // the user chose for the whole machine -- the accent among them. + // It reaches only what that theme styles, which is what the admin + // console wears; see Layouts. + UIManager.getInstance().setUseNativeColors(true); + UIManager.getInstance().setUseNativeFonts(true); + } + UIManager.getInstance().refreshTheme(); + } + + /// Whether the app is drawn dark right now. A platform that cannot say is + /// taken to be light. + public static boolean dark() { + Boolean dark = CN.isDarkMode(); + return dark != null && dark.booleanValue(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Menu.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Menu.java new file mode 100644 index 00000000000..1262a383c9a --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Menu.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Session; +import java.util.ArrayList; +import java.util.List; + +/// The menu of a home screen: who is signed in, which of the app's four modes +/// this is, the places that mode has, and the way to another mode. +/// +/// Each mode fills in its own places with [#item]; nothing here knows what a +/// member's or the front desk's are. What is the same for all of them is the top -- +/// the person and the mode -- and the bottom: the other modes the account's +/// roles open, Settings, and signing out. An account with one role is offered +/// no other mode, because it has none. +/// +/// It slides in over the screen as a layer of the screen's own, not as the +/// title bar's side menu: the home screens have no title bar, and on a desktop +/// the title bar's menu is handed to the window's. +/// +/// On a wide window -- a tablet on its side, a desktop, a browser -- there is +/// room to keep it in view, so it is docked down the side of the home screen +/// and of every screen opened from it, with the place that is open marked, and +/// the button that opens it is not shown. See [Layouts#wide()]. +public final class Menu { + /// The menu of the home screen showing on a wide window, which every + /// screen opened from it docks beside its own content; null on a phone + /// and while nobody is signed in. + private static Menu docked; + /// The width of the docked menu, in millimetres. + private static final float SIDE_MM = 58f; + /// The name of the place last opened from the docked menu. + private static String picked; + /// The client property that marks a screen whose menu is docked. + private static final String DOCKED = "pulse.docked"; + private final Form form; + private final String mode; + /// The mode's places as they were added: icon, words, name and what each does. + private final List items = new ArrayList(); + private final Container layer = new Container(new BorderLayout()); + private final Container places = new Container(BoxLayout.y()); + private final Container panel; + + /// @param mode the mode `form` is the home of: `Nav.MEMBER` and the others + public Menu(Form form, String mode) { + this.form = form; + this.mode = mode; + panel = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + // Most of a phone's width, and no wider than a menu wants to + // be on a tablet or a desktop. + Dimension size = super.calcPreferredSize(); + size.setWidth(Math.min(CN.getDisplayWidth() * 5 / 6, CN.convertToPixels(78f))); + return size; + } + }; + panel.setUIID("SvMenu"); + panel.setSafeArea(true); + panel.setGrabsPointerEvents(true); + panel.setName("menu"); + build(panel, places); + // What is left of the screen beside the menu: dimmed, and a tap on it + // puts the menu away. + Button dim = new Button("", "SvScrim"); + dim.setName("menu-dim"); + dim.addActionListener(e -> close()); + layer.add(BorderLayout.WEST, panel); + layer.add(BorderLayout.CENTER, dim); + layer.setHidden(true); + layer.setVisible(false); + } + + /// Adds the menu to the screen, closed, as its top layer. Call it after + /// the screen's own content has been added. + public void install() { + if (Layouts.wide()) { + docked = this; + picked = items.isEmpty() ? null : (String) items.get(0)[2]; + dock(form); + return; + } + form.add(layer); + } + + /// Docks the menu of the home screen down the side of `screen`, on a wide + /// window while someone is signed in; does nothing anywhere else. A + /// screen whose content is laid out in borders gets it as its west, any + /// other has its content moved beside it. + public static void dock(Form screen) { + if (docked == null || !Layouts.wide() + || Boolean.TRUE.equals(screen.getClientProperty(DOCKED))) { + return; + } + screen.putClientProperty(DOCKED, Boolean.TRUE); + // A screen that is one of the places marks it; one opened from a + // place -- a member, an account -- keeps the place it came from. + for (int iter = 0; iter < docked.items.size(); iter++) { + Object[] item = docked.items.get(iter); + if (Lang.tr((String) item[1]).equals(screen.getTitle())) { + picked = (String) item[2]; + } + } + Container side = docked.side(); + if (screen instanceof Screen) { + ((Screen) screen).dockBeside(side); + return; + } + // A home screen: its content is layered, and built once. + Container content = screen.getContentPane(); + Container rest = new Container(new com.codename1.ui.layouts.LayeredLayout()); + while (content.getComponentCount() > 0) { + com.codename1.ui.Component child = content.getComponentAt(0); + content.removeComponent(child); + rest.add(child); + } + Container both = new Container(new BorderLayout()); + both.add(BorderLayout.WEST, side); + both.add(BorderLayout.CENTER, rest); + content.add(both); + } + + /// Forgets the docked menu: nobody is signed in any more. + public static void undock() { + docked = null; + picked = null; + } + + /// A fresh copy of the menu for the side of a screen, with the place that + /// is open marked. + private Container side() { + Container dock = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + // As wide on every screen, whatever its longest entry. + Dimension size = super.calcPreferredSize(); + size.setWidth(CN.convertToPixels(SIDE_MM)); + return size; + } + }; + dock.setUIID("SvMenuDock"); + dock.setName("menu"); + Container docks = new Container(BoxLayout.y()); + for (int iter = 0; iter < items.size(); iter++) { + Object[] item = items.get(iter); + final String name = (String) item[2]; + final Runnable go = (Runnable) item[3]; + // Marked before the place opens, so that its screen docks with it marked. + Button entry = entry(((Character) item[0]).charValue(), (String) item[1], + "menu-" + name, () -> { + picked = name; + go.run(); + }); + if (name.equals(picked)) { + entry.setUIID("SvMenuItemPicked"); + } + docks.add(entry); + } + build(dock, docks); + return dock; + } + + /// The round button that opens the menu, for the screen to place. + public Button button() { + Button button = Ui.round(FontImage.MATERIAL_MENU, "menu-open", e -> open()); + button.setAccessibilityText(Lang.tr("Menu")); + return button; + } + + /// The button that opens the menu, for a screen with a bar and no map. + public Button flatButton() { + Button button = Ui.flat(FontImage.MATERIAL_MENU, "Menu", "menu-open", e -> open()); + // Docked, the menu is in view and there is nothing to open. + button.setHidden(Layouts.wide()); + return button; + } + + /// Adds one of the mode's own places. + /// + /// @param name the button's name is `menu-` and this + public Menu item(char icon, String text, String name, final Runnable go) { + places.add(entry(icon, text, "menu-" + name, go)); + items.add(new Object[] {Character.valueOf(icon), text, name, go}); + return this; + } + + public void open() { + if (!layer.isHidden()) { + return; + } + layer.setHidden(false); + layer.setVisible(true); + form.getContentPane().revalidate(); + // Laid out where it belongs; started from off the edge it comes from, + // which is the right-hand one in a right-to-left language. + panel.setX(panel.isRTL() ? layer.getWidth() : -panel.getWidth()); + layer.animateLayout(200); + } + + public void close() { + if (layer.isHidden()) { + return; + } + layer.setHidden(true); + layer.setVisible(false); + form.getContentPane().revalidate(); + } + + private void build(Container panel, Container places) { + UserDto user = Session.user(); + Container top = new Container(BoxLayout.y()); + top.setUIID("SvMenuTop"); + Container who = new Container(new BorderLayout()); + Container names = new Container(BoxLayout.y()); + names.add(Ui.label(user.displayName(), "SvMenuName")); + names.add(Ui.label(user.username(), "SvMenuDetail")); + who.add(BorderLayout.WEST, Ui.avatar(user.displayName())); + who.add(BorderLayout.CENTER, names); + top.add(who); + // Which of the three apps this is, said in words at the top of each. + top.add(FlowLayout.encloseIn(Ui.badge(Nav.OWNER.equals(mode) ? "Owner mode" + : Nav.TRAINER.equals(mode) ? "Trainer mode" + : Nav.DESK.equals(mode) ? "Front desk" : "Member", "SvModeBadge"))); + + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + list.setScrollVisible(false); + list.add(places); + // The other modes, for an account whose roles open more than one. + // Every account is a member; coaching, the desk and owning are granted. + if (user.trainer() || user.desk() || user.owner() || !Nav.MEMBER.equals(mode)) { + list.add(Ui.label("Switch to", "SvMenuLabel")); + if (!Nav.MEMBER.equals(mode)) { + list.add(other(FontImage.MATERIAL_FITNESS_CENTER, "Member", Nav.MEMBER)); + } + if (user.desk() && !Nav.DESK.equals(mode)) { + list.add(other(FontImage.MATERIAL_QR_CODE_SCANNER, "Front desk", Nav.DESK)); + } + if (user.trainer() && !Nav.TRAINER.equals(mode)) { + list.add(other(FontImage.MATERIAL_SPORTS, "Coaching", Nav.TRAINER)); + } + if (user.owner() && !Nav.OWNER.equals(mode)) { + list.add(other(FontImage.MATERIAL_INSIGHTS, "Studio owner", Nav.OWNER)); + } + } + + Container bottom = new Container(BoxLayout.y()); + bottom.setUIID("SvMenuBottom"); + bottom.add(entry(FontImage.MATERIAL_SETTINGS, "Settings", "menu-settings", + () -> SettingsForm.show(form))); + bottom.add(entry(FontImage.MATERIAL_LOGOUT, "Sign out", "menu-signOut", Nav::signOut)); + + panel.add(BorderLayout.NORTH, top); + panel.add(BorderLayout.CENTER, list); + panel.add(BorderLayout.SOUTH, bottom); + } + + private Button other(char icon, String text, final String to) { + return entry(icon, text, "switch-" + to, () -> Nav.switchTo(to)); + } + + private Button entry(char icon, String text, String name, final Runnable go) { + Button entry = new Button(text, "SvMenuItem"); + entry.setName(name); + Ui.icon(entry, icon, 3.8f); + entry.addActionListener(e -> { + close(); + go.run(); + }); + return entry; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Nav.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Nav.java new file mode 100644 index 00000000000..842dc28bee9 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Nav.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.AppConfig; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.desk.DeskForm; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.member.MemberForm; +import com.codenameone.examples.pulse.member.NudgesForm; +import com.codenameone.examples.pulse.member.Reminders; +import com.codenameone.examples.pulse.net.Account; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.notify.Notifications; +import com.codenameone.examples.pulse.owner.OwnerForm; +import com.codenameone.examples.pulse.trainer.TrainerForm; +import com.codenameone.examples.pulse.watch.WatchBridge; + +/// Decides which screen the app is on. +/// +/// The app is four apps in one -- for training, for the front desk, for +/// coaching and for running the studio -- and which of them a user may enter is +/// the server's decision: it is the roles on their account, as `/api/me` +/// reports them. Each has a home screen of its own, with its own menu, and the +/// user is in one of them at a time. Hiding a mode here is a courtesy and not +/// the protection; the server refuses a desk, trainer or owner call from anyone +/// without the role, whatever the app shows. +public final class Nav { + public static final String MEMBER = "member"; + public static final String DESK = "desk"; + public static final String TRAINER = "trainer"; + public static final String OWNER = "owner"; + + /// The home screen of a mode. + public interface Home { + /// Fills the screen in and starts it talking to the server. Called + /// once, before the screen is first shown. + void start(); + + Form form(); + } + + /// The home screen last built, and the mode it is the home of. + private static Form homeForm; + private static String homeMode = ""; + + private Nav() { + } + + /// Whether `form` is still the home screen, for an answer that arrives + /// after the user has gone to another mode or signed out and should then + /// change nothing. + public static boolean isHome(Form form) { + return form != null && form == homeForm; + } + + /// The first screen: wherever the stored session leads. + public static void launch() { + Session.onSignedOut(() -> { + LiveChannel.stop(); + Telemetry.signedOut("expired"); + WelcomeForm.show(); + }); + Form starting = Ui.form("", "Starting"); + starting.getToolbar().hideToolbar(); + starting.show(); + Session.restore(Nav::enter, WelcomeForm::show, Nav::unreachable); + } + + /// Someone has just signed in, or been found still signed in: their + /// account's settings are fetched, applied, and then their home is shown in + /// the language and the colours they chose. + public static void enter() { + // Where this phone is to be sent notifications, now that there is + // someone to send them to. + Notifications.signedIn(); + // Plan prices are shown in the server's currency, which only the + // payment config says. + Api.payments().config(Net.to(config -> Ui.setCurrency(config.currency()), (status, message) -> { })); + Account.load(() -> { + Look.apply(); + Lang.apply(); + if (Telemetry.asked()) { + home(); + } else { + // Once on a device, before the first screen that would be + // counted: whether it may be. + ConsentForm.show(Nav::home); + } + }); + } + + /// Shows the home screen of the mode the user is in. + public static void home() { + Form home = build(); + if (home != null) { + home.show(); + } + } + + /// The account was asked about again: back to the home screen only if + /// the mode it opens in is no longer the one on screen, so somebody whose + /// roles did not change is not pulled out of what they were doing. + public static void refreshed() { + UserDto user = Session.user(); + if (user == null || !mode(user).equals(homeMode)) { + home(); + } + } + + /// Builds the home screen of the mode the user is in, without showing it: + /// for a caller that is about to show something on top of it. Null, and + /// the welcome screen showing, when nobody is signed in. + public static Form build() { + UserDto user = Session.user(); + if (user == null) { + WelcomeForm.show(); + return null; + } + LiveChannel.start(); + String mode = mode(user); + Telemetry.role(mode); + Home home = OWNER.equals(mode) ? new OwnerForm() + : TRAINER.equals(mode) ? new TrainerForm() + : DESK.equals(mode) ? new DeskForm() + : new MemberForm(); + home.start(); + homeForm = home.form(); + homeMode = mode; + return homeForm; + } + + /// The mode to open for `user`. + /// + /// The one they chose last, while their account still allows it. With no + /// choice made, the one their account is for: an owner runs the studio, a + /// desk account is a kiosk, a trainer coaches, and everyone else trains. + /// A choice is remembered for the account that made it, so the next person + /// to sign in on the same device starts where their own account does. + public static String mode(UserDto user) { + String saved = AppConfig.mode(Session.owner(user.username())); + if (OWNER.equals(saved) && user.owner()) { + return OWNER; + } + if (TRAINER.equals(saved) && user.trainer()) { + return TRAINER; + } + if (DESK.equals(saved) && user.desk()) { + return DESK; + } + if (MEMBER.equals(saved)) { + return MEMBER; + } + return user.owner() ? OWNER : user.desk() ? DESK + : user.trainer() ? TRAINER : MEMBER; + } + + /// The user chose `mode` from the menu. + public static void switchTo(String mode) { + UserDto user = Session.user(); + AppConfig.setMode(mode, user == null ? "" : Session.owner(user.username())); + home(); + } + + /// A push notification was opened: the member's inbox, over the member's + /// home, whatever mode the app was in. + public static void showNudges() { + UserDto user = Session.user(); + if (user == null) { + return; + } + if (!MEMBER.equals(homeMode) || homeForm == null) { + AppConfig.setMode(MEMBER, Session.owner(user.username())); + Form home = build(); + if (home == null) { + return; + } + NudgesForm.show(home); + return; + } + NudgesForm.show(homeForm); + } + + public static void signOut() { + leave("user", true); + } + + /// The account was closed: signed out, and what the device kept for the + /// usage reports forgotten with it. + public static void deleted() { + // Closing the account already made the server forget its devices. + leave("deleted", false); + Telemetry.forget(); + // Nobody is coming back for where that account went. + Prefs.ownedBy(""); + } + + private static void leave(final String reason, boolean forgetDevice) { + Menu.undock(); + Account.flush(); + Reminders.clear(); + WatchBridge.clear(); + LiveChannel.stop(); + homeForm = null; + homeMode = ""; + // The server forgets this phone for the account first, while there is + // still a session to ask with. + Runnable out = () -> { + Telemetry.signedOut(reason); + Session.signOut(WelcomeForm::show); + }; + if (forgetDevice) { + Notifications.signingOut(out); + } else { + out.run(); + } + } + + /// Points the app at another server, which ends the session with this one. + public static void changeServer(final String url) { + Runnable switchOver = () -> { + AppConfig.setServerUrl(url); + Session.start(AppConfig.serverUrl()); + launch(); + }; + if (AppConfig.sameServer(url, Session.serverUrl()) || Session.user() == null) { + LiveChannel.stop(); + switchOver.run(); + return; + } + // Signed in: sign out the way the Sign out button does, against the + // server still in use, before pointing anywhere else. Session.start + // only forgets the tokens on this phone, so the old server would keep + // a live refresh token and this phone's push registration, and go on + // notifying it about that account's classes. + Account.flush(); + Reminders.clear(); + WatchBridge.clear(); + LiveChannel.stop(); + homeForm = null; + homeMode = ""; + Notifications.signingOut(() -> { + Telemetry.signedOut("server"); + Session.signOut(switchOver); + }); + } + + /// There is a session, and no server to use it with. + private static void unreachable() { + Form form = Ui.form("Pulse", "Unreachable"); + Container page = Ui.page(); + page.add(Ui.label("No connection", "SvTitle")); + page.add(Ui.text(Lang.tr("The server at {0} did not answer.", Session.serverUrl()), + "SvText")); + page.add(Ui.primary("Try again", "retry", e -> launch())); + if (AppConfig.serverSwitchable()) { + page.add(Ui.secondary("Server address", "server", e -> ServerForm.show(form))); + } + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ProfileForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ProfileForm.java new file mode 100644 index 00000000000..ee5331fa1e6 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ProfileForm.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.api.ProfileDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Account; +import com.codenameone.examples.pulse.net.Session; + +/// Who the user is to the studio: the name coaches and the front desk see. +public final class ProfileForm { + private ProfileForm() { + } + + /// @param saved where to go once the profile is saved: a screen that shows + /// the name is built again there + public static void show(final Form previous, final Runnable saved) { + final Form form = Ui.form("Profile", "Profile"); + Ui.back(form, previous); + UserDto user = Session.user(); + ProfileDto profile = Account.profile(); + Container page = Ui.page(); + + Ui.section(page, "About you"); + final TextField name = Ui.field(page, "Name", "name", + "What your coaches will call you"); + name.setText(profile == null ? user.displayName() : profile.name()); + + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button save = Ui.primary("Save", "submit", null); + save.addActionListener(e -> { + save.setEnabled(false); + error.setText(""); + Account.saveProfile(name.getText().trim(), saved, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }); + }); + page.add(error).add(save); + + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/RegisterForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/RegisterForm.java new file mode 100644 index 00000000000..92ddd280544 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/RegisterForm.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.RegisterDto; +import com.codenameone.examples.pulse.AppConfig; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; + +/// Creates an account, signs in with it and goes on to verify the phone. +/// +/// Everyone who registers is a member, free to look at the timetable and to +/// join a plan. Coaching, the front desk and owning the studio are roles the +/// owner grants; nobody registers into them. +public final class RegisterForm { + private RegisterForm() { + } + + public static void show(Form previous) { + final Form form = Ui.form("Create an account", "Register"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", "What your coaches will call you"); + final TextField email = Ui.field(page, "E-mail", "email", "you@example.com", + TextArea.EMAILADDR); + final TextField phone = Ui.field(page, "Mobile number", "phone", "+1 555 010 0000", + TextArea.PHONENUMBER); + final TextField password = Ui.field(page, "Password", "password", + "At least 8 characters", TextArea.PASSWORD); + + final TextField birth = Ui.field(page, "Date of birth", "birthDate", "YYYY-MM-DD"); + page.add(Ui.text("Some classes are for an age band, so the timetable needs to know. " + + "Children are added later, from Household.", "SvMuted")); + + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button create = Ui.primary("Create account", "submit", null); + create.addActionListener(e -> { + final RegisterDto request = new RegisterDto(email.getText().trim(), password.getText(), + name.getText().trim(), phone.getText().trim(), birth.getText().trim(), null); + final Net.Failed failed = (status, message) -> { + create.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }; + create.setEnabled(false); + error.setText(""); + // The server checks every field and says what is wrong with the + // first that fails, so nothing is validated twice. + Api.account().register(request, Net.to(user -> Session.signIn(request.email(), + request.password(), () -> { + AppConfig.setMode(Nav.MEMBER, Session.owner(request.email())); + Telemetry.registered(); + VerifyPhoneForm.show(Nav::enter); + }, failed), failed)); + }); + page.add(error).add(create); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ring.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ring.java new file mode 100644 index 00000000000..720985449cf --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ring.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A countdown drawn as a ring that empties, with the seconds left inside it. +/// +/// The colours are two styles' text colours: `SvRing` for the part of the +/// ring still left and `SvRingTrack` for the part used up. Neither has a +/// background, so whatever the ring sits on shows through its middle. +public final class Ring extends Component { + private long endsAt; + private int total = 1; + private int shown = -1; + + public Ring() { + setUIID("SvRing"); + } + + /// Starts counting down from `seconds`, of a count that began at `of`. + public void start(int seconds, int of) { + total = Math.max(1, Math.max(seconds, of)); + endsAt = System.currentTimeMillis() + seconds * 1000L; + repaint(); + } + + /// The whole seconds left, never less than none. + public int secondsLeft() { + long left = endsAt - System.currentTimeMillis(); + return left <= 0 ? 0 : (int) ((left + 999) / 1000); + } + + @Override + protected void initComponent() { + super.initComponent(); + Form form = getComponentForm(); + if (form != null) { + form.registerAnimated(this); + } + } + + @Override + protected void deinitialize() { + Form form = getComponentForm(); + if (form != null) { + form.deregisterAnimated(this); + } + super.deinitialize(); + } + + /// Repaints while the count is running, which is what makes the ring + /// shrink smoothly and not a second at a time. + @Override + public boolean animate() { + boolean running = endsAt > System.currentTimeMillis() || shown != 0; + shown = secondsLeft(); + return running; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + int side = CN.convertToPixels(10.5f); + return new Dimension(side + style.getHorizontalPadding(), + side + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int track = UIManager.getInstance().getComponentStyle("SvRingTrack").getFgColor(); + int side = Math.min(getWidth() - style.getHorizontalPadding(), + getHeight() - style.getVerticalPadding()); + if (side <= 0) { + return; + } + int centerX = getX() + getWidth() / 2; + int centerY = getY() + getHeight() / 2; + int stroke = Math.max(2, CN.convertToPixels(0.9f)); + int radius = (side - stroke) / 2; + long left = Math.max(0L, endsAt - System.currentTimeMillis()); + double part = Math.min(1d, left / (total * 1000d)); + + // The ring as dots around the circle, the first at the top and going + // clockwise: enough of them that they join up. + g.setAntiAliased(true); + int dots = Math.max(60, (int) (2 * Math.PI * radius / Math.max(1, stroke / 2))); + int lit = (int) Math.round(dots * part); + for (int dot = 0; dot < dots; dot++) { + double angle = 2 * Math.PI * dot / dots; + int x = centerX + (int) Math.round(Math.sin(angle) * radius); + int y = centerY - (int) Math.round(Math.cos(angle) * radius); + g.setColor(dot < lit ? style.getFgColor() : track); + g.fillArc(x - stroke / 2, y - stroke / 2, stroke, stroke, 0, 360); + } + + String text = String.valueOf(secondsLeft()); + Font font = style.getFont(); + g.setFont(font); + g.setColor(style.getFgColor()); + g.drawString(text, centerX - font.stringWidth(text) / 2, centerY - font.getHeight() / 2); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Screen.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Screen.java new file mode 100644 index 00000000000..4a09d97ffc0 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Screen.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// A screen pushed on top of a home screen, which on a wide window shows the +/// home screen's menu docked down its side. +/// +/// The menu is docked as the screen is shown, once the screen has its +/// content: what was in the content pane moves into a body beside the menu, +/// with the place each piece had. Everything the screen adds or takes away +/// after that goes to the body, so a screen is written the same way for a +/// phone and a desktop and never knows the menu is there. +final class Screen extends Form { + private Container body; + + Screen(String title) { + super(title, new BorderLayout()); + } + + @Override + public void show() { + Menu.dock(this); + super.show(); + } + + @Override + public void showBack() { + Menu.dock(this); + super.showBack(); + } + + /// Moves the content beside `side`. Called once, by the menu. + void dockBeside(Container side) { + Container content = getContentPane(); + Container moved = new Container(new BorderLayout()); + while (content.getComponentCount() > 0) { + Component child = content.getComponentAt(0); + Object where = content.getLayout().getComponentConstraint(child); + content.removeComponent(child); + if (where == null) { + moved.add(child); + } else { + moved.add(where, child); + } + } + content.add(BorderLayout.WEST, side); + content.add(BorderLayout.CENTER, moved); + body = moved; + } + + @Override + public void addComponent(Component cmp) { + if (body == null) { + super.addComponent(cmp); + } else { + body.addComponent(cmp); + } + } + + @Override + public void addComponent(Object constraints, Component cmp) { + if (body == null) { + super.addComponent(constraints, cmp); + } else { + body.addComponent(constraints, cmp); + } + } + + @Override + public void addComponent(int index, Object constraints, Component cmp) { + if (body == null) { + super.addComponent(index, constraints, cmp); + } else { + body.addComponent(index, constraints, cmp); + } + } + + @Override + public void addComponent(int index, Component cmp) { + if (body == null) { + super.addComponent(index, cmp); + } else { + body.addComponent(index, cmp); + } + } + + @Override + public void removeComponent(Component cmp) { + if (body != null && cmp.getParent() == body) { + body.removeComponent(cmp); + } else { + super.removeComponent(cmp); + } + } + + @Override + public void removeAll() { + if (body == null) { + super.removeAll(); + } else { + body.removeAll(); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ServerForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ServerForm.java new file mode 100644 index 00000000000..8c5baf9ef12 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ServerForm.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.net.Session; + +/// Where the server is. A released app has one answer and no use for this +/// screen; while you are developing, it is how a phone is pointed at the server +/// running on your computer -- `http://` and the computer's address on the +/// network, since `localhost` on a phone is the phone. +public final class ServerForm { + private ServerForm() { + } + + public static void show(Form previous) { + Form form = Ui.form("Server address", "Server"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text("The address of the Pulse server this app talks to. " + + "Changing it signs you out.", "SvText")); + final TextField url = Ui.field(page, "Address", "url", "http://192.168.1.20:8080", + TextArea.URL); + url.setText(Session.serverUrl()); + page.add(Ui.primary("Save", "submit", e -> Nav.changeServer(url.getText()))); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SettingsForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SettingsForm.java new file mode 100644 index 00000000000..ffae7904680 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SettingsForm.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.pulse.AppConfig; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.AccountClosureDto; +import com.codenameone.examples.pulse.api.PasswordChangeDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.pay.WalletForm; + +/// Settings: the account, and everything about the app a person may want +/// their own way. +/// +/// Every control here reads and writes [Prefs] and nothing else, so this +/// screen does not know where a setting is kept: `Prefs` keeps it on the +/// device, and `net.Account` hears of the change and keeps it with the account. +/// The three under Appearance change what is already on screen -- the +/// language, light or dark, miles or kilometres -- and take effect at once: the +/// look and the words are applied, and the screens are built again in them, +/// this one included. +public final class SettingsForm { + private SettingsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Settings", "Settings"); + Ui.back(form, previous); + final UserDto user = Session.user(); + Container page = Ui.page(); + + page.add(Ui.person(user.displayName(), user.username(), null)); + + Ui.section(page, "Account"); + Container account = group(); + account.add(Ui.tap(Ui.row(FontImage.MATERIAL_PERSON, "Profile", + "Your name", chevron()), "profile", + e -> ProfileForm.show(form, () -> show(previous)))); + account.add(Ui.row(FontImage.MATERIAL_PHONE, user.phone(), + user.phoneVerified() ? null : "Needed for reminders and the front desk", + user.phoneVerified() ? Ui.badge("Verified", "SvBadgeGood") + : Ui.link("Verify", "verify", + e -> VerifyPhoneForm.show(() -> show(previous))))); + account.add(Ui.tap(Ui.row(FontImage.MATERIAL_LOCK, "Password", + "Change the password you sign in with", chevron()), "password", + e -> password(form))); + page.add(account); + + Ui.section(page, "Payment"); + Container pay = group(); + pay.add(Ui.tap(Ui.row(FontImage.MATERIAL_ACCOUNT_BALANCE_WALLET, "Wallet", + "Your cards, and which of them pays by default", chevron()), "wallet", + e -> WalletForm.show(form))); + pay.add(Ui.tap(Ui.row(FontImage.MATERIAL_CARD_MEMBERSHIP, "Membership", + "Your plan, freezes and invoices", chevron()), "membership", + e -> com.codenameone.examples.pulse.member.MembershipForm.show(form))); + page.add(pay); + + Ui.section(page, "Notifications"); + Container notify = group(); + notify.add(Ui.row(FontImage.MATERIAL_ALARM, "Class reminders", + "An hour before every class you booked", + Ui.toggle("notifyClasses", Prefs.notifyClasses(), + Prefs::setNotifyClasses))); + notify.add(Ui.row(FontImage.MATERIAL_RECEIPT, "Receipts", "After every payment", + Ui.toggle("notifyReceipts", Prefs.notifyReceipts(), Prefs::setNotifyReceipts))); + notify.add(Ui.row(FontImage.MATERIAL_LOCAL_OFFER, "Offers", "Challenges, offers and news", + Ui.toggle("notifyPromotions", Prefs.notifyPromotions(), + Prefs::setNotifyPromotions))); + page.add(notify); + + Ui.section(page, "Appearance"); + Container look = group(); + look.add(Ui.label("Language", "SvLabel")); + String speaking = Lang.current(); + for (int iter = 0; iter < Lang.CODES.length; iter++) { + final String code = Lang.CODES[iter]; + Label tick = new Label("", "SvRowIcon"); + if (code.equals(speaking)) { + Ui.icon(tick, FontImage.MATERIAL_CHECK, 3.4f); + } + Container row = new Container(new BorderLayout()); + row.setUIID("SvRow"); + // A language is called what its speakers call it, whatever + // language the rest of the screen is in. + row.add(BorderLayout.CENTER, Ui.plain(Lang.name(code), "SvRowTitle")); + row.add(BorderLayout.EAST, tick); + look.add(Ui.tap(row, "language-" + code, e -> { + boolean other = !code.equals(Lang.current()); + Prefs.setLanguage(code); + if (other) { + Telemetry.languageChanged(code); + } + again(previous); + })); + } + look.add(Ui.label("Theme", "SvLabel")); + look.add(Ui.choice("theme", new String[] {"System", "Light", "Dark"}, + new String[] {Prefs.THEME_SYSTEM, Prefs.THEME_LIGHT, Prefs.THEME_DARK}, + Prefs.theme(), value -> { + if (!value.equals(Prefs.theme())) { + Prefs.setTheme(value); + Telemetry.themeChanged(value); + } + again(previous); + })); + look.add(Ui.label("Distances", "SvLabel")); + look.add(Ui.choice("units", new String[] {"Kilometres", "Miles"}, + new String[] {Prefs.UNITS_KM, Prefs.UNITS_MILES}, Prefs.units(), value -> { + Prefs.setUnits(value); + again(previous); + })); + page.add(look); + + Ui.section(page, "Privacy"); + Container safe = group(); + // Kept on this device and not with the account: it is this + // installation that reports or does not. + safe.add(Ui.row(FontImage.MATERIAL_INSIGHTS, "Usage statistics", + "Share which screens and features are used. Never your name, places or " + + "payment details.", + Ui.toggle("usageStatistics", Telemetry.allowed(), Telemetry::allow))); + page.add(safe); + + Ui.section(page, "About"); + Container about = group(); + about.add(Ui.row(FontImage.MATERIAL_INFO, "Pulse", + "A sample gym and studio app built with Codename One", null)); + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_DESCRIPTION, "Terms of service", null, + chevron()), "terms", e -> legal(form, "Terms of service", + "Pulse is a sample. No real classes are booked, no studio is " + + "opened to you and no money changes hands.", null))); + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_POLICY, "Privacy policy", null, chevron()), + "privacy", e -> legal(form, "Privacy policy", + "Your bookings, visits, household and settings are kept with your " + + "account on the server you signed in to. Your membership pass is a " + + "code this phone works out from a secret that server gave it, and " + + "nothing else about you is in it.", + "With usage statistics turned on, the app also counts which screens " + + "are opened and which features are used, and reports that to " + + "Codename One's analytics service under a made-up id for this " + + "device. Your name, e-mail, phone number, places and card details " + + "are never part of it, and turning it off in Settings stops it."))); + if (AppConfig.serverSwitchable()) { + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_DNS, "Server address", null, chevron()), + "server", e -> ServerForm.show(form))); + } + page.add(about); + + page.add(Ui.secondary("Sign out", "signOut", e -> Nav.signOut())); + page.add(Ui.link("Delete my account", "deleteAccount", e -> delete(form))); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// Applies the look and the language, and shows everything again in them. + /// + /// A screen that is already built keeps the words and colours it was built + /// with, so the mode's first screen is built anew to go back to, and this + /// one over it. + private static void again(Form previous) { + Look.apply(); + Lang.apply(); + Form home = Nav.build(); + show(home == null ? previous : home); + } + + static Container group() { + Container group = new Container(BoxLayout.y()); + group.setUIID("SvGroup"); + return group; + } + + /// The mark at the end of a row that leads somewhere. + static Label chevron() { + Label chevron = new Label("", "SvRowIcon"); + Ui.icon(chevron, FontImage.MATERIAL_CHEVRON_RIGHT, 3.4f); + return chevron; + } + + /// Changing the password. The server wants the current one as well as the + /// new: being signed in is what a borrowed phone is, too. + private static void password(final Form previous) { + final Form form = Ui.form("Password", "Password"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField current = Ui.field(page, "Current password", "current", "", + TextArea.PASSWORD); + final TextField replacement = Ui.field(page, "New password", "replacement", + "At least 8 characters", TextArea.PASSWORD); + final TextField again = Ui.field(page, "New password, again", "again", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button save = Ui.primary("Change password", "submit", null); + save.addActionListener(e -> { + if (!replacement.getText().equals(again.getText())) { + error.setText(Lang.tr("The two new passwords are not the same.")); + Ui.refresh(form); + return; + } + PasswordChangeDto change = new PasswordChangeDto(current.getText(), + replacement.getText()); + save.setEnabled(false); + error.setText(""); + Api.account().changePassword(change, Net.to(user -> { + Ui.say("Your password was changed"); + previous.showBack(); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(error).add(save); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// Closing the account, which cannot be taken back, so it is its own + /// screen that says what goes and has one red button. + private static void delete(final Form previous) { + final Form form = Ui.form("Delete account", "DeleteAccount"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.banner(FontImage.MATERIAL_DELETE_FOREVER, "Delete your account?", + "Your sign-in, your profile, your saved cards and your settings are removed " + + "for good. Classes you attended stay in the studio's records, under the email " + + "address you signed in with.", + "SvBannerIconBad")); + // Asked for again, as changing the password asks: being signed in is + // what a borrowed phone is, too, and the server refuses without it. + final TextField password = Ui.field(page, "Current password", "password", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + page.add(error); + final Button go = Ui.danger("Delete my account", "confirmDelete", null); + go.addActionListener(e -> { + go.setEnabled(false); + AccountClosureDto closure = new AccountClosureDto(password.getText()); + Api.account().deleteAccount(closure, Net.to(gone -> Nav.deleted(), (status, message) -> { + go.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(go); + page.add(Ui.link("Keep my account", "keep", e -> previous.showBack())); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static void legal(Form previous, String title, String text, String more) { + Form form = Ui.form(title, "Legal"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text(text, "SvText")); + if (more != null) { + page.add(Ui.text(more, "SvText")); + } + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SignInForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SignInForm.java new file mode 100644 index 00000000000..4897ff47e58 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/SignInForm.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.net.Session; + +/// E-mail and password. The form is the app's own; what happens when it is +/// submitted is in [Session#signIn]. +public final class SignInForm { + private SignInForm() { + } + + public static void show(Form previous) { + final Form form = Ui.form("Sign in", "SignIn"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField email = Ui.field(page, "E-mail", "email", "you@example.com", + TextArea.EMAILADDR); + final TextField password = Ui.field(page, "Password", "password", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button signIn = Ui.primary("Sign in", "submit", null); + signIn.addActionListener(e -> { + if (email.getText().trim().length() == 0 || password.getText().length() == 0) { + error.setText("Enter your e-mail and password."); + Ui.refresh(form); + return; + } + signIn.setEnabled(false); + error.setText(""); + Session.signIn(email.getText().trim(), password.getText(), () -> { + Telemetry.signedIn(); + Nav.enter(); + }, (status, message) -> { + signIn.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }); + }); + page.add(error).add(signIn); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Skeleton.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Skeleton.java new file mode 100644 index 00000000000..11aefb9dc1b --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Skeleton.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +/// What a list shows while what is in it is on its way: the outline of a few +/// rows, drawn as bars where the icon, the two lines and the value will be. +/// +/// It says "this is loading" without a word and without moving, and it keeps +/// the screen from jumping when the rows arrive, because they land where the +/// outline was. The colour is the `SvSkeleton` style's. +public final class Skeleton extends Component { + /// How tall one row is drawn, in millimetres: a row of two lines. + private static final float ROW_MM = 9.6f; + + private final int rows; + + public Skeleton(int rows) { + this.rows = rows; + setUIID("SvSkeleton"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f) + style.getHorizontalPadding(), + rows * CN.convertToPixels(ROW_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int width = getWidth() - style.getHorizontalPadding(); + int row = CN.convertToPixels(ROW_MM); + int dot = CN.convertToPixels(4.4f); + int tall = CN.convertToPixels(1.9f); + int thin = CN.convertToPixels(1.5f); + int gap = CN.convertToPixels(2.6f); + if (width <= dot + gap) { + return; + } + boolean rtl = isRTL(); + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + int lines = width - dot - gap; + for (int iter = 0; iter < rows; iter++) { + int top = getY() + style.getPaddingTop() + iter * row; + int middle = top + row / 2; + // Each row a little shorter than the one before: a list of + // identical bars reads as a pattern, not as a list. + int first = lines * (48 - iter % 3 * 7) / 100; + int second = lines * (30 + iter % 2 * 9) / 100; + int value = lines * 14 / 100; + bar(g, rtl, left, width, 0, middle - dot / 2, dot, dot); + bar(g, rtl, left, width, dot + gap, middle - tall - thin / 3, first, tall); + bar(g, rtl, left, width, dot + gap, middle + thin * 2 / 3, second, thin); + bar(g, rtl, left, width, width - value, middle - tall / 2, value, tall); + } + } + + /// One bar, `from` pixels in from the side a line of text starts at. + private static void bar(Graphics g, boolean rtl, int left, int width, int from, int y, + int w, int h) { + int x = rtl ? left + width - from - w : left + from; + g.fillRoundRect(x, y, w, h, h, h); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Split.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Split.java new file mode 100644 index 00000000000..45d50083ced --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Split.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +/// A list and what was picked from it, side by side, on a wide window. +/// +/// On a phone a row of a list opens its own screen. On a tablet on its side, a +/// desktop or a browser there is room for both, so the list keeps the left and +/// the row picked fills the right -- working down a list of members or +/// accounts no longer means going back to it each time. A screen asks +/// [Layouts#wide()] whether to make one; the detail is drawn by the same code +/// that draws the phone's screen, into [#detail()]. +public final class Split { + /// The width of the list, in millimetres. + private static final float MASTER_MM = 110f; + private final Container detail = Ui.page(); + private Container picked; + + /// Lays `top` -- a search field, a row of filters -- and `list` out on + /// the left of `form`, and the detail on the right. + public Split(Form form, Component top, Component list) { + Container master = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension size = super.calcPreferredSize(); + size.setWidth(CN.convertToPixels(MASTER_MM)); + return size; + } + }; + master.setUIID("SvMaster"); + if (top != null) { + master.add(BorderLayout.NORTH, top); + } + master.add(BorderLayout.CENTER, list); + detail.setName("detail"); + Container both = new Container(new BorderLayout()); + both.add(BorderLayout.WEST, master); + both.add(BorderLayout.CENTER, detail); + form.add(BorderLayout.CENTER, both); + } + + /// Where the row picked is drawn. + public Container detail() { + return detail; + } + + /// Whether a row has been picked yet. + public boolean hasPick() { + return picked != null; + } + + /// Marks `row` as the one picked, and unmarks the last. + public void pick(Container row) { + if (picked != null) { + picked.setUIID("SvRow"); + } + picked = row; + row.setUIID("SvRowPicked"); + row.getParent().repaint(); + } + + /// Forgets the row picked: the list was filled again. + public void clear() { + picked = null; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/StatRow.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/StatRow.java new file mode 100644 index 00000000000..140fc520267 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/StatRow.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; + +import java.util.ArrayList; +import java.util.List; + +/// A row of dashboard numbers, each over its caption, sized as one thing. +/// +/// A label asked to fit its own text decides alone and after the fact: it +/// changes its font once it has been given a width, by which time the tile +/// around it has its height from the font it had before, and its neighbour +/// has decided something else. The result was a row whose numbers were three +/// sizes and whose captions were cut off, or pushed out of the tile. +/// +/// So the row decides, before it lays its tiles out and from the width it +/// really has: one size for every number, the largest at which the longest +/// fits, and one for every caption, which goes onto a second line before it +/// is made any smaller. +public final class StatRow extends Container { + /// The smallest a number and a caption are drawn, in millimetres. + private static final float SMALLEST_VALUE = 2.6f; + private static final float SMALLEST_CAPTION = 1.9f; + + private final int columns; + private final List tiles = new ArrayList(); + private int laidOutAt; + private String fitted = ""; + private String planned = ""; + private boolean correcting; + + StatRow(int columns) { + super(new GridLayout(1, columns)); + this.columns = columns; + } + + /// Adds a number: `value` as it is, over `caption` in the user's language. + /// The number's label is called `name`. + public StatRow stat(String value, String caption, String name) { + Tile tile = new Tile(value, Lang.tr(caption), name); + tiles.add(tile); + add(tile); + return this; + } + + /// Takes every number out, for a row that is filled again. + public void clear() { + tiles.clear(); + removeAll(); + } + + @Override + protected Dimension calcPreferredSize() { + // Asked how tall it is before it knows how wide: answered for the + // width it last had, or the room its nearest sized ancestor offers. + fit(laidOutAt > 0 ? laidOutAt : roomOffered()); + planned = fitted; + return super.calcPreferredSize(); + } + + @Override + public void layoutContainer() { + int width = getWidth(); + if (width > 0) { + laidOutAt = width; + fit(width); + if (!fitted.equals(planned) && !correcting) { + // The height was answered for another width, and the tiles + // are a different height at this one. + correcting = true; + CN.callSerially(() -> { + correcting = false; + setShouldCalcPreferredSize(true); + Form form = getComponentForm(); + if (form != null) { + form.revalidate(); + } + }); + } + } + super.layoutContainer(); + } + + private int roomOffered() { + Container parent = getParent(); + while (parent != null) { + if (parent.getWidth() > 0) { + return parent.getWidth() - parent.getStyle().getHorizontalPadding(); + } + parent = parent.getParent(); + } + return CN.getDisplayWidth(); + } + + private void fit(int width) { + if (tiles.isEmpty() || width <= 0) { + return; + } + int across = Math.min(columns, tiles.size()); + Tile first = tiles.get(0); + int room = (width - getStyle().getHorizontalPadding()) / across + - first.getStyle().getHorizontalPadding() + - first.getStyle().getHorizontalMargins(); + if (room <= 0) { + return; + } + float value = 1f; + float caption = 1f; + for (Tile tile : tiles) { + value = Math.min(value, share(room - tile.value.sides(), tile.value.width(tile.number))); + tile.split(room - tile.upper.sides()); + caption = Math.min(caption, share(room - tile.upper.sides(), tile.widest())); + } + StringBuilder made = new StringBuilder(); + for (Tile tile : tiles) { + made.append(tile.value.scale(value, SMALLEST_VALUE)).append('/'); + made.append(tile.upper.scale(caption, SMALLEST_CAPTION)).append('/'); + tile.lower.scale(caption, SMALLEST_CAPTION); + made.append(tile.show()).append(' '); + } + fitted = made.toString(); + } + + /// The part of its full size at which text `needed` pixels wide fits + /// `room`. + private static float share(int room, int needed) { + return needed <= room || needed <= 0 ? 1f : Math.max(0f, room) / (float) needed; + } + + /// One number over its caption. + private static final class Tile extends Container { + private final String number; + private final String caption; + private final Sized value; + private final Sized upper; + private final Sized lower; + private String top; + private String bottom; + + Tile(String number, String caption, String name) { + super(BoxLayout.y()); + setUIID("SvStat"); + this.number = number == null ? "" : number; + this.caption = caption == null ? "" : caption; + value = new Sized(Ui.plain(this.number, "SvStatValue")); + value.label.setName(name); + upper = new Sized(Ui.plain(this.caption, "SvStatLabel")); + lower = new Sized(Ui.plain("", "SvStatLabel")); + top = this.caption; + bottom = ""; + add(value.label).add(upper.label).add(lower.label); + show(); + } + + /// Puts the caption on one line if `room` holds it, and otherwise on + /// two, broken at the space that leaves the longer line shortest. + void split(int room) { + top = caption; + bottom = ""; + int whole = upper.width(caption); + if (whole <= room) { + return; + } + int best = whole; + for (int at = caption.indexOf(' '); at > 0; at = caption.indexOf(' ', at + 1)) { + String before = caption.substring(0, at); + String after = caption.substring(at + 1); + int longer = Math.max(upper.width(before), upper.width(after)); + if (longer < best) { + best = longer; + top = before; + bottom = after; + } + } + } + + int widest() { + return Math.max(upper.width(top), upper.width(bottom)); + } + + /// Writes the lines decided by [#split]; answers how many there are. + int show() { + if (!top.equals(upper.label.getText())) { + upper.label.setText(top); + } + if (!bottom.equals(lower.label.getText())) { + lower.label.setText(bottom); + } + boolean second = bottom.length() > 0; + if (lower.label.isHidden() == second) { + lower.label.setHidden(!second); + lower.label.setVisible(second); + } + return second ? 2 : 1; + } + } + + /// A label and the font its style gave it, which is the size it is drawn + /// at when there is room. The style is asked again every time, because a + /// label is restyled under us: the desktop console gives it another name, + /// and a change of theme gives it another font. + private static final class Sized { + private final Label label; + private Font styled; + private Font applied; + private int appliedPixels; + + Sized(Label label) { + this.label = label; + } + + private Font styled() { + Font now = label.getUnselectedStyle().getFont(); + if (now != applied || styled == null) { + styled = now; + applied = now; + appliedPixels = Math.round(now.getPixelSize()); + } + return styled; + } + + int sides() { + return label.getUnselectedStyle().getHorizontalPadding() + + label.getUnselectedStyle().getHorizontalMargins(); + } + + /// How wide `text` is at the full size. + int width(String text) { + return text.length() == 0 ? 0 : styled().stringWidth(text); + } + + /// Draws the label at `share` of its full size, and never smaller + /// than `smallest` millimetres; answers the size in pixels. + int scale(float share, float smallest) { + Font full = styled(); + int most = Math.round(full.getPixelSize()); + if (most <= 0) { + // Not a font that can be drawn at another size. + return 0; + } + int least = Math.min(most, CN.convertToPixels(smallest)); + int pixels = share >= 1f ? most : Math.max(least, (int) Math.floor(most * share)); + if (pixels != appliedPixels) { + applied = pixels == most ? full : full.derive(pixels, full.getStyle()); + appliedPixels = pixels; + label.getAllStyles().setFont(applied); + } + return pixels; + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ui.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ui.java new file mode 100644 index 00000000000..a7667bde2ad --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/Ui.java @@ -0,0 +1,851 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.components.Switch; +import com.codename1.components.ToastBar; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.Prefs; +import com.codenameone.examples.pulse.Telemetry; + +import java.util.Date; + +/// The pieces every screen is made of. Each one is a component with a style +/// name from `theme.css`; nothing here, or in any screen, sets a colour or a +/// font in code. +public final class Ui { + /// What goes between two short facts on one line: a raised dot. + public static final String DOT = " \u00b7 "; + + private Ui() { + } + + /// A screen with a title bar. + /// + /// @param name what a test waits for; the title is for people and changes + public static Form form(String title, String name) { + Form form = new Screen(title); + form.setUIID("SvForm"); + form.getContentPane().setUIID("SvForm"); + form.setName(name); + watch(form); + clearOfTheStatusBar(form); + form.addSizeChangedListener(e -> clearOfTheStatusBar(form)); + return form; + } + + /// Keeps the title bar of `form` under what the device draws over the top + /// of the screen -- the clock, a notch -- the same way the map screens + /// keep their floating bar under it: by the inset the device reports. The + /// strip a form reserves for the status bar is as tall as the theme makes + /// it, which says nothing of a notch; the title bar is padded by whatever + /// that strip leaves uncovered. + private static void clearOfTheStatusBar(Form form) { + Container bar = form.getToolbar(); + if (bar == null) { + return; + } + Rectangle safe = Display.getInstance().getDisplaySafeArea(new Rectangle()); + int covered = Math.max(0, safe.getY()); + Container area = bar.getParent(); + int kept = 0; + if (area != null && area.getLayout() instanceof BorderLayout) { + Component strip = ((BorderLayout) area.getLayout()).getNorth(); + if (strip != null && strip != bar) { + kept = strip.getPreferredH(); + } + } + int styled = UIManager.getInstance().getComponentStyle("Toolbar").getPaddingTop(); + int wanted = styled + Math.max(0, covered - kept); + if (bar.getUnselectedStyle().getPaddingTop() != wanted) { + bar.getAllStyles().setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); + bar.getAllStyles().setPaddingTop(wanted); + } + } + + private static void clearOfTheNotch(Form form) { + Rectangle safe = Display.getInstance().getDisplaySafeArea(new Rectangle()); + int covered = Math.max(0, safe.getY()); + Style style = form.getContentPane().getAllStyles(); + if (form.getContentPane().getUnselectedStyle().getPaddingTop() != covered) { + style.setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); + style.setPaddingTop(covered); + } + } + + /// Gives `label` a material icon `size` millimetres tall, and the space + /// between it and the label's words: without one the two touch. + public static void icon(Label label, char icon, float size) { + FontImage.setMaterialIcon(label, icon, size); + // An icon with no words gets no gap: the gap counts in the centring + // even with nothing beside it, which pushes a lone icon off the middle + // of its circle. + String text = label.getText(); + label.setGap(text == null || text.length() == 0 ? 0 : CN.convertToPixels(ICON_GAP)); + } + + /// The space between an icon and the words beside it, in millimetres. + private static final float ICON_GAP = 1.4f; + + /// Has `form` counted as a screen each time it is shown, under its name. + /// Every screen of the app is made by this class or handed to this method, + /// so this is the one place a screen view is reported from. + public static void watch(final Form form) { + form.addShowListener(e -> viewed(form.getName())); + } + + /// Counts something that takes the place of a screen without being a + /// form: a pane of the admin's console on a wide window. + public static void viewed(String name) { + Telemetry.screen(name); + } + + /// The first screen of a mode: no title bar, and layers, so that a map can + /// fill the screen with the controls floating over it and the menu sliding + /// in over both. + public static Form home(String name) { + Form form = new Form("", new LayeredLayout()); + form.setUIID("SvForm"); + // The strips the device keeps clear at the top and bottom are the + // content pane's; the native theme would paint them its own grey. + form.getContentPane().setUIID("SvForm"); + form.setName(name); + watch(form); + form.getToolbar().hideToolbar(); + // With no title bar, nothing keeps the home bar out from under the + // clock and the notch: the content pane does, by the same inset a + // pushed screen's title bar is kept clear by, so the two line up. + clearOfTheNotch(form); + form.addSizeChangedListener(e -> clearOfTheNotch(form)); + // What is on the screen scrolls, where it needs to. The screen itself + // does not, or it would offer to scroll the map away. + form.setScrollable(false); + return form; + } + + /// Gives `form` a back arrow that returns to `previous`. + public static void back(Form form, final Form previous) { + back(form, () -> previous.showBack()); + } + + /// Gives `form` a back arrow that runs `leave`. + /// + /// On a desktop whose window owns the title bar there is no title bar in + /// the app to carry an arrow. The command is then named, so that it is in + /// the window's menu, and the screen gets a line of its own to go back + /// from. A screen that has put something of its own at its top keeps it, + /// under that line: a screen with no way back is one nobody can leave + /// without a device's back button, and a desktop has none. + public static void back(final Form form, final Runnable leave) { + boolean window = CN.isDesktop() && form.getToolbar().getParent() == null; + Command back = form.getToolbar().setBackCommand(window ? Lang.tr("Back") : "", + e -> leave.run()); + if (!window) { + Button arrow = form.getToolbar().findCommandComponent(back); + if (arrow != null) { + arrow.setAccessibilityText(Lang.tr("Back")); + arrow.setName("back"); + } + return; + } + back.setDesktopMenu(Command.DESKTOP_MENU_VIEW); + back.setDesktopShortcut('['); + final boolean[] added = new boolean[1]; + form.addShowListener(e -> { + if (added[0] || form.getToolbar().getParent() != null + || !(form.getContentPane().getLayout() instanceof BorderLayout)) { + return; + } + added[0] = true; + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvPaneBar"); + Button arrow = button("Back", "back-bar", "SvLink", e2 -> leave.run()); + icon(arrow, FontImage.MATERIAL_ARROW_BACK, 3.4f); + bar.add(BorderLayout.WEST, arrow); + Component top = ((BorderLayout) form.getContentPane().getLayout()).getNorth(); + if (top == null) { + form.add(BorderLayout.NORTH, bar); + } else { + top.remove(); + Container both = new Container(BoxLayout.y()); + both.add(bar).add(top); + form.add(BorderLayout.NORTH, both); + } + Layouts.dress(bar); + form.revalidate(); + }); + } + + /// A column of content with the page's margins, that scrolls when it has to. + public static Container page() { + Container page = new Column(); + page.setUIID("SvPage"); + page.setScrollableY(true); + page.setScrollVisible(false); + page.setSafeArea(true); + return page; + } + + public static Container card() { + Container card = new Container(BoxLayout.y()); + card.setUIID("SvCard"); + return card; + } + + public static Label label(String text, String uiid) { + return new Label(text == null ? "" : text, uiid); + } + + /// Text that wraps. + public static SpanLabel text(String text, String uiid) { + SpanLabel label = new SpanLabel(text == null ? "" : text); + label.setUIID("Container"); + label.setTextUIID(uiid); + return label; + } + + public static Button primary(String text, String name, ActionListener onClick) { + return button(text, name, "SvPrimary", onClick); + } + + public static Button secondary(String text, String name, ActionListener onClick) { + return button(text, name, "SvSecondary", onClick); + } + + public static Button danger(String text, String name, ActionListener onClick) { + return button(text, name, "SvDanger", onClick); + } + + public static Button link(String text, String name, ActionListener onClick) { + return button(text, name, "SvLink", onClick); + } + + /// A round button that floats over the content. + public static Button round(char icon, String name, ActionListener onClick) { + Button button = button("", name, "SvMapButton", onClick); + icon(button, icon, 4f); + return button; + } + + /// A screen's main action, docked under its content with the page's own + /// margins, so that it never runs edge to edge. + public static Container dock(Component action) { + Container dock = new Container(new BorderLayout()); + dock.setUIID("SvDock"); + dock.setSafeArea(true); + dock.add(BorderLayout.CENTER, action); + return dock; + } + + /// The same button for a bar that is a surface of its own: flat, since a + /// shadow says "this floats", and on a bar nothing does. + /// + /// @param spoken what a screen reader says for it, since it has no text + public static Button flat(char icon, String spoken, String name, + ActionListener onClick) { + Button button = button("", name, "SvBarButton", onClick); + icon(button, icon, 4f); + button.setAccessibilityText(Lang.tr(spoken)); + return button; + } + + /// One of a row of things to do: an icon with a word under it. + public static Button action(char icon, String text, String name, + ActionListener onClick) { + Button button = button(text, name, "SvAction", onClick); + icon(button, icon, 4f); + button.setTextPosition(Component.BOTTOM); + button.setGap(CN.convertToPixels(0.6f)); + return button; + } + + /// A short fact in a pill: how far, how long. The icon may be 0. + public static Label chip(String text, char icon) { + Label chip = new Label(text == null ? "" : text, "SvChip"); + if (icon != 0) { + icon(chip, icon, 2.7f); + } + return chip; + } + + /// A person, as the initials of their name in a circle. + public static Label avatar(String name) { + Label avatar = new Label("", "SvAvatar"); + // Initials are not words: looking them up would translate "OK". + avatar.setShouldLocalize(false); + avatar.setText(initials(name)); + // One colour per person, so two people with the same initials still + // read as two people. + int[] palette = {0xdc3418, 0x7c4dff, 0x1f9d6b, 0x2f6fd6, 0xe08a00, 0xc2185b}; + int hash = name == null ? 0 : name.hashCode(); + avatar.getAllStyles().setBorder(com.codename1.ui.plaf.RoundBorder.create() + .color(palette[(hash & 0x7fffffff) % palette.length])); + return avatar; + } + + /// The first letters of the first and last words of `name`. + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return "?"; + } + int space = text.lastIndexOf(' '); + String first = text.substring(0, 1); + return space < 0 || space + 1 >= text.length() ? first + : first + text.substring(space + 1, space + 2); + } + + public interface Flipped { + void flipped(boolean on); + } + + /// A switch that reports each change. + public static Switch toggle(String name, boolean value, final Flipped flipped) { + final Switch toggle = new Switch(); + toggle.setName(name); + toggle.setValue(value); + toggle.addChangeListener(e -> flipped.flipped(toggle.isValue())); + return toggle; + } + + public interface Chosen { + void chosen(String value); + } + + /// A few options side by side, one of them selected. Each button is named + /// `name-value`. + /// + /// @param labels what each option is called, for people + /// @param values what each option is, for the code + public static Container choice(String name, String[] labels, final String[] values, + String current, final Chosen chosen) { + return choice(name, labels, values, current, chosen, values.length); + } + + /// The same, in rows of `columns`, for options that do not fit in one. + public static Container choice(String name, String[] labels, final String[] values, + String current, final Chosen chosen, int columns) { + Container row = new Container(new GridLayout((values.length + columns - 1) / columns, + columns)); + row.setUIID("SvSegments"); + ButtonGroup group = new ButtonGroup(); + for (int iter = 0; iter < values.length; iter++) { + final String value = values[iter]; + RadioButton option = RadioButton.createToggle(labels[iter], group); + option.setUIID("SvSegment"); + option.setName(name + "-" + value); + option.setSelected(value.equals(current)); + option.addActionListener(e -> chosen.chosen(value)); + row.add(option); + } + return row; + } + + /// Options as a row of pills that scrolls sideways, one chosen: days of a + /// week, people in a household. Unlike [#choice] no option is squeezed, + /// however many there are. + public static Container pills(String name, String[] labels, final String[] values, + String current, final Chosen chosen) { + Container row = new Container(BoxLayout.x()); + row.setUIID("SvPills"); + row.setScrollableX(true); + row.setScrollVisible(false); + ButtonGroup group = new ButtonGroup(); + for (int iter = 0; iter < values.length; iter++) { + final String value = values[iter]; + RadioButton option = RadioButton.createToggle(labels[iter], group); + option.setUIID("SvPill"); + option.setName(name + "-" + value); + option.setSelected(value.equals(current)); + option.addActionListener(e -> chosen.chosen(value)); + row.add(option); + } + return row; + } + + /// The longest address that is still shown on one line. + private static final int LONG_ADDRESS = 26; + + /// Who a screen is about: their initials, their name, the address they sign + /// in with, and under those `value` when there is something to say about + /// them. The address wraps rather than being cut off; it is what tells + /// two people with the same name apart. + public static Container person(String name, String username, Component value) { + Container who = new Container(new BorderLayout()); + who.setUIID("SvPanel"); + Container names = new Container(BoxLayout.y()); + Label called = plain(name, "SvHeading"); + shrink(called, 3f); + String signsIn = username == null ? "" : username; + Component address; + if (signsIn.length() > LONG_ADDRESS) { + // An address has no spaces to break at: a long one is broken + // where the line ends, which keeps all of it readable. + SpanLabel wrapped = text("", "SvMuted"); + wrapped.setShouldLocalize(false); + wrapped.setText(signsIn); + address = wrapped; + } else { + Label line = plain(signsIn, "SvMuted"); + shrink(line, 2f); + address = line; + } + names.add(called).add(address); + if (value != null) { + names.add(FlowLayout.encloseIn(value)); + } + who.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(avatar(name))); + who.add(BorderLayout.CENTER, names); + return who; + } + + /// Lets `label` draw its text smaller, down to `smallest` millimetres, + /// when it would otherwise be cut off -- and never larger than its style + /// says, which is what fitting text to its space does when left alone. + public static void shrink(Label label, float smallest) { + float styled = label.getUnselectedStyle().getFont().getPixelSize() + / (float) CN.convertToPixels(1f); + label.setAutoSizeMode(true); + label.setMaxAutoSize(styled); + label.setMinAutoSize(Math.min(smallest, styled)); + } + + /// Lays out the screen `changed` is on, after its content changed. Laying + /// out `changed` alone keeps the height it had before: a list that grew + /// from a placeholder to a dozen cards stays clipped to the placeholder. + public static void relayout(Container changed) { + Form form = changed.getComponentForm(); + if (form != null) { + form.revalidateLater(); + } else { + changed.revalidateLater(); + } + } + + /// Lays `form` out again after its content changed while it was showing. + /// + /// Twice, the second time after the first has been drawn: text that wraps + /// learns its width from the first pass, and only then knows how many + /// lines it is. + public static void refresh(final Form form) { + form.revalidate(); + CN.callSerially(() -> form.revalidate()); + } + + /// The heading over a group of settings or facts. + public static void section(Container page, String title) { + page.add(label(title, "SvSection")); + } + + /// A label for text that is a name or a number, not a word to translate. + public static Label plain(String text, String uiid) { + Label label = new Label("", uiid); + label.setShouldLocalize(false); + label.setText(text == null ? "" : text); + return label; + } + + /// One line of a receipt or a record: what it is on one side, and its + /// value, which is never translated, on the other. + public static Container fact(String caption, String value) { + return fact(caption, value, "SvFactValue"); + } + + public static Container fact(String caption, String value, String valueUiid) { + Container fact = new FactRow(label(caption, "SvFactLabel"), plain(value, valueUiid)); + fact.setUIID("SvFact"); + return fact; + } + + /// What a screen shows instead of a list with nothing in it, and at the top + /// of a screen that is about one thing: a large icon, a line, and a few + /// words under it. + public static Container banner(char icon, String title, String text, String iconUiid) { + Container banner = new Container(BoxLayout.y()); + banner.setUIID("SvBanner"); + Label image = new Label("", iconUiid); + icon(image, icon, 6f); + banner.add(FlowLayout.encloseCenter(image)); + SpanLabel heading = text(title, "SvBannerTitle"); + heading.getTextComponent().setAlignment(Component.CENTER); + banner.add(heading); + if (text != null && text.length() > 0) { + SpanLabel words = text(text, "SvBannerText"); + words.getTextComponent().setAlignment(Component.CENTER); + banner.add(words); + } + return banner; + } + + /// Something to read before acting on what follows -- a customer's note + /// for the kitchen: an icon, a bold title and the words, in a tinted box + /// in the flow of the page. [#banner] is for a screen with nothing else + /// on it, and is far too loud between two sections. + public static Container callout(char icon, String title, String text) { + Container box = new Container(new BorderLayout()); + box.setUIID("SvCallout"); + Label image = new Label("", "SvCalloutIcon"); + icon(image, icon, 3.6f); + box.add(BorderLayout.WEST, FlowLayout.encloseIn(image)); + Container words = new Container(BoxLayout.y()); + words.add(label(title, "SvCalloutTitle")); + if (text != null && text.length() > 0) { + words.add(text(text, "SvCalloutText")); + } + box.add(BorderLayout.CENTER, words); + return box; + } + + public static Container empty(char icon, String title, String text) { + return banner(icon, title, text, "SvBannerIcon"); + } + + /// A list that fills a screen: rows from edge to edge, scrolling. + public static Container list(String name) { + Container list = new Column(); + list.setUIID("SvList"); + list.setScrollableY(true); + list.setScrollVisible(false); + list.setName(name); + // Until it is filled: every list is emptied by what fills it. + list.add(new Skeleton(5)); + return list; + } + + /// Has a pull down on `list` fetch what is in it again, with `load`. A + /// window on a desktop is not pulled, and gets nothing. + public static void pull(Container list, final Runnable load) { + if (!CN.isDesktop()) { + list.addPullToRefresh(load); + } + } + + /// A column that stays a column: on a window wider than a column of text + /// should be, it keeps its content in the middle rather than stretching + /// every row and every button from one edge to the other. + private static final class Column extends Container { + private String styled; + private int margin; + + Column() { + super(BoxLayout.y()); + } + + @Override + public void layoutContainer() { + // The margin is the style's own, read once for each style the + // column is given: what is set below replaces it. + if (!getUIID().equals(styled)) { + styled = getUIID(); + margin = getUnselectedStyle().getPaddingLeftNoRTL(); + } + Layouts.centre(this, margin); + super.layoutContainer(); + } + } + + public interface Reason { + void given(String reason); + } + + /// Asks why, before something an admin does to someone else's account or + /// class. The reason is kept with what was done, so it is not optional. + /// + /// @param action what the button says, which is what will be done + public static void reason(final Form previous, String title, String text, String action, + final Reason reason) { + final Form form = form(title, "Reason"); + back(form, previous); + form.add(BorderLayout.CENTER, reasonPage(text, action, reason)); + form.show(); + } + + /// What [#reason] asks with, for a caller that has its own place to put it. + public static Container reasonPage(String text, String action, final Reason reason) { + final Container page = page(); + page.add(text(text, "SvText")); + final TextField why = field(page, "Reason", "reason", "A sentence the record will keep"); + final SpanLabel error = text("", "SvError"); + error.setName("error"); + page.add(error); + page.add(danger(action, "confirm", e -> { + String given = why.getText().trim(); + if (given.length() == 0) { + error.setText(Lang.tr("Say why, in a few words.")); + page.getComponentForm().revalidate(); + return; + } + reason.given(given); + })); + return page; + } + + private static Button button(String text, String name, String uiid, + ActionListener onClick) { + Button button = new Button(text, uiid); + // The name is what a test finds the button by. + button.setName(name); + if (onClick != null) { + button.addActionListener(onClick); + } + return button; + } + + /// Adds a caption and a text field under it to `to`, and returns the field. + public static TextField field(Container to, String caption, String name, String hint, + int constraint) { + TextField field = new TextField("", hint, 24, constraint); + field.setUIID("SvField"); + field.getHintLabel().setUIID("SvFieldHint"); + field.setName(name); + to.add(label(caption, "SvLabel")).add(field); + return field; + } + + public static TextField field(Container to, String caption, String name, String hint) { + return field(to, caption, name, hint, TextArea.ANY); + } + + /// A row of a list: an icon, two lines of text, and something at the end. + /// The icon may be 0, and the last two null. + public static Container row(char icon, String title, String detail, Component value) { + Container row = new Container(new BorderLayout()); + row.setUIID("SvRow"); + if (icon != 0) { + Label image = new Label("", "SvRowIcon"); + icon(image, icon, 4f); + row.add(BorderLayout.WEST, image); + } + Container lines = new Container(BoxLayout.y()); + lines.add(label(title, "SvRowTitle")); + if (detail != null && detail.length() > 0) { + lines.add(text(detail, "SvRowDetail")); + } + row.add(BorderLayout.CENTER, lines); + if (value != null) { + // Its own height, in the middle of the row: straight into EAST it + // would be stretched to the height of the row. + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(value)); + } + return row; + } + + /// Makes the whole of `row` one button. The name is the button's, for a + /// test to click. + /// + /// The lead is an empty, unpadded button rather than a hidden one: a + /// hidden component is left out of the accessibility tree, and a screen + /// reader then had no way to activate the row. It reads what the row says. + public static Container tap(Container row, String name, ActionListener onClick) { + Button lead = new Button("", "SvLead"); + lead.setName(name); + lead.setAccessibilityText(spoken(row, new StringBuilder()).toString()); + lead.addActionListener(onClick); + if (!(row.getLayout() instanceof BorderLayout)) { + // The lead goes in a border's south; any other layout is wrapped. + row = BorderLayout.center(row); + } + row.add(BorderLayout.SOUTH, lead); + row.setLeadComponent(lead); + return row; + } + + /// The text of every label in `parent`, in order, comma separated. + private static StringBuilder spoken(Container parent, StringBuilder out) { + for (int iter = 0; iter < parent.getComponentCount(); iter++) { + Component child = parent.getComponentAt(iter); + String text = null; + if (child instanceof Label) { + text = ((Label) child).getText(); + } else if (child instanceof SpanLabel) { + text = ((SpanLabel) child).getText(); + } else if (child instanceof Container) { + spoken((Container) child, out); + } + if (text != null && text.length() > 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(text); + } + } + return out; + } + + /// A day, without the time. + public static String day(long millis) { + return new SimpleDateFormat("MMM d, yyyy").format(new Date(millis)); + } + + /// A day as a list heading: "Monday, Nov 2". + public static String weekday(long millis) { + return new SimpleDateFormat("EEEE, MMM d").format(new Date(millis)); + } + + /// A moment, as a short date and time. + public static String when(long millis) { + return new SimpleDateFormat("MMM d, HH:mm").format(new Date(millis)); + } + + public static Label badge(String text, String uiid) { + return new Label(text, uiid); + } + + /// A row of numbers, each with a caption under it, `columns` across: + /// add them with [StatRow#stat]. More than `columns` of them run onto a + /// further row. + public static StatRow stats(int columns) { + return new StatRow(columns); + } + + /// Tells the user something failed. The signature is `Net.Failed`'s, so a + /// call can end `Ui::fail`. + public static void fail(int status, String message) { + // What the server says is the server's wording; what the app says of + // its own is translated. Looking both up costs nothing. + ToastBar.showErrorMessage(Lang.tr(message)); + } + + /// What a list's load ends with when it fails: the user is told, as with + /// [#fail], and a list still showing the outline of rows that are not + /// coming shows what went wrong instead, with a way to ask again. A list + /// that already has rows keeps them. + /// + /// @param load fetches the list again, or null when there is no asking again + public static Net.Failed failed(final Container list, final Runnable load) { + return (status, message) -> { + fail(status, message); + if (list.getComponentCount() != 1 || !(list.getComponentAt(0) instanceof Skeleton)) { + return; + } + list.removeAll(); + list.add(empty(FontImage.MATERIAL_CLOUD_OFF, message, null)); + if (load != null) { + list.add(FlowLayout.encloseCenter(link("Try again", "retry", e -> { + list.removeAll(); + list.add(new Skeleton(5)); + list.revalidate(); + load.run(); + }))); + } + list.revalidate(); + }; + } + + public static void say(String message) { + ToastBar.showInfoMessage(Lang.tr(message)); + } + + /// The server's currency, from the payment config; null until it is known. + private static String currency; + + /// Records the currency the server prices everything in. + public static void setCurrency(String code) { + currency = code; + } + + /// An amount of money. Whole cents in and text out: `$12.40` for dollars, + /// and the amount followed by the currency's code for anything else. + public static String money(long cents, String currency) { + if (currency == null || currency.length() == 0) { + // Menus, options and carts carry no currency of their own: they are + // in the one the server is configured for (pulse.currency). + currency = Ui.currency; + } + long whole = Math.abs(cents / 100); + long part = Math.abs(cents % 100); + String sign = cents < 0 ? "-" : ""; + String amount = grouped(whole) + "." + (part < 10 ? "0" : "") + part; + if (currency == null || currency.length() == 0 || "USD".equals(currency)) { + return sign + "$" + amount; + } + return sign + amount + " " + currency; + } + + // Thousands grouped with commas, "1,187": a dashboard's figures are read + // at a glance. + private static String grouped(long whole) { + String digits = String.valueOf(whole); + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < digits.length(); iter++) { + if (iter > 0 && (digits.length() - iter) % 3 == 0) { + out.append(','); + } + out.append(digits.charAt(iter)); + } + return out.toString(); + } + + /// An amount for a stat tile: whole units when there are no cents, so + /// "$25" fits where "$25.00" was cut off. + public static String moneyShort(long cents, String currency) { + String full = money(cents, currency); + return cents % 100 == 0 ? full.replace(".00", "") : full; + } + + /// A distance, in the units chosen in Settings. + public static String distance(double meters) { + if (Prefs.UNITS_MILES.equals(Prefs.units())) { + double feet = meters * 3.28084; + if (feet < 900) { + return Lang.tr("{0} ft", String.valueOf(Math.round(feet / 50d) * 50)); + } + return Lang.tr("{0} mi", tenths(meters / 1609.344)); + } + if (meters < 950) { + return Lang.tr("{0} m", String.valueOf(Math.round(meters / 50d) * 50)); + } + return Lang.tr("{0} km", tenths(meters / 1000d)); + } + + /// `value` with one digit after the point. + public static String tenths(double value) { + long tenths = Math.round(value * 10d); + return (tenths / 10) + "." + (tenths % 10); + } + + public static String minutes(double seconds) { + return Lang.tr("{0} min", String.valueOf(Math.max(1L, Math.round(seconds / 60d)))); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/VerifyPhoneForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/VerifyPhoneForm.java new file mode 100644 index 00000000000..8884065b6b5 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/VerifyPhoneForm.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.pulse.Telemetry; +import com.codenameone.examples.pulse.api.PhoneCodeDto; +import com.codenameone.examples.pulse.api.UserDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import com.codenameone.examples.pulse.net.Session; + +/// Proves the phone number is the user's, which the server asks for before the +/// first class. +/// +/// The code is the server's: it makes it up, texts it, and is the only one who +/// checks it. The app never sees the code unless the server is a development +/// one with no SMS provider configured, which sends it back so the flow can be +/// tried without a phone. +public final class VerifyPhoneForm { + private VerifyPhoneForm() { + } + + /// @param after where to go once the number is verified, or the user + /// decides to leave it for later + public static void show(final Runnable after) { + final Form form = Ui.form("Verify your number", "VerifyPhone"); + UserDto user = Session.user(); + Container page = Ui.page(); + page.add(Ui.text(user == null || user.phone() == null + ? Lang.tr("We sent a six-digit code to your phone.") + : Lang.tr("We sent a six-digit code to {0}.", user.phone()), "SvText")); + final SpanLabel demo = Ui.text("", "SvNotice"); + demo.setName("demoCode"); + demo.setHidden(true); + page.add(demo); + final TextField code = Ui.field(page, "Code", "code", "000000", TextArea.NUMERIC); + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button verify = Ui.primary("Verify", "submit", null); + verify.addActionListener(e -> { + PhoneCodeDto entered = new PhoneCodeDto(code.getText().trim()); + verify.setEnabled(false); + error.setText(""); + Api.account().verifyPhone(entered, Net.to(verified -> { + Session.setUser(verified); + Telemetry.phoneVerified(); + after.run(); + }, (status, message) -> { + verify.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + final Runnable send = () -> Api.account().startPhoneVerification(Net.to(challenge -> { + if (challenge.demoCode() != null && challenge.demoCode().length() > 0) { + demo.setText(Lang.tr("This is a development server, so no text was " + + "sent. Your code is {0}.", challenge.demoCode())); + demo.setHidden(false); + Ui.refresh(form); + } + }, (status, message) -> { + error.setText(message); + Ui.refresh(form); + })); + page.add(error).add(verify); + page.add(Ui.link("Send a new code", "resend", e -> send.run())); + page.add(Ui.link("Not now", "skip", e -> after.run())); + form.add(BorderLayout.CENTER, page); + form.show(); + send.run(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/WelcomeForm.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/WelcomeForm.java new file mode 100644 index 00000000000..94badd6e54d --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/WelcomeForm.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.pulse.AppConfig; + +/// What a signed-out user sees. +public final class WelcomeForm { + private WelcomeForm() { + } + + private static Container feature(char glyph, String text) { + Container row = new Container(new BorderLayout(), "SvWelcomeFeature"); + Label icon = new Label("", "SvWelcomeFeatureIcon"); + Ui.icon(icon, glyph, 3.6f); + row.add(BorderLayout.WEST, icon); + row.add(BorderLayout.CENTER, Ui.text(text, "SvWelcomeFeatureText")); + return row; + } + + public static void show() { + final Form form = Ui.form("", "Welcome"); + form.getContentPane().setUIID("SvWelcome"); + form.getToolbar().hideToolbar(); + + Container hero = new Container(BoxLayout.y()); + hero.setUIID("SvWelcome"); + hero.setSafeArea(true); + Container mark = new Container(new BorderLayout(), "SvInkRow"); + Label icon = new Label("", "SvWelcomeIcon"); + Ui.icon(icon, FontImage.MATERIAL_MONITOR_HEART, 7f); + mark.add(BorderLayout.WEST, icon); + Beat beat = new Beat("SvBeat", true); + beat.setPreferredH(com.codename1.ui.CN.convertToPixels(9)); + mark.add(BorderLayout.CENTER, beat); + hero.add(mark); + hero.add(Ui.label("Pulse", "SvWelcomeTitle")); + hero.add(Ui.text("Your studio in your pocket.", "SvWelcomeTag")); + hero.add(feature(FontImage.MATERIAL_EVENT_AVAILABLE, + "Book classes for you and your family in two taps")); + hero.add(feature(FontImage.MATERIAL_QR_CODE, + "Walk in with a pass on your phone or your watch")); + hero.add(feature(FontImage.MATERIAL_EMOJI_EVENTS, + "Earn points and badges for every class you show up to")); + + Container actions = Ui.page(); + actions.setScrollableY(false); + actions.setUIID("SvWelcomeActions"); + actions.setSafeArea(true); + actions.add(Ui.primary("Sign in", "signIn", e -> SignInForm.show(form))); + actions.add(Ui.secondary("Create an account", "register", e -> RegisterForm.show(form))); + if (AppConfig.serverSwitchable()) { + actions.add(Ui.link("Server address", "server", e -> ServerForm.show(form))); + } + + form.add(BorderLayout.CENTER, hero); + form.add(BorderLayout.SOUTH, actions); + form.show(); + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ZoneBar.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ZoneBar.java new file mode 100644 index 00000000000..deef347a34e --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/ui/ZoneBar.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import java.util.List; + +/// The minutes of a workout in each heart rate zone, as one bar split in the +/// zones' colours, easy to hard from left to right. +public final class ZoneBar extends Component { + /// Zone 1 to 5: easy grey, blue, green, orange, red. + private static final int[] COLORS = {0x9aa3ad, 0x3d8bfd, 0x2bb673, 0xff9f1c, 0xe5383b}; + private final int[] minutes = new int[COLORS.length]; + private int total; + + public ZoneBar(List zones) { + setUIID("SvZoneBar"); + for (int iter = 0; zones != null && iter < zones.size() && iter < COLORS.length; iter++) { + Integer value = zones.get(iter); + minutes[iter] = value == null ? 0 : Math.max(0, value.intValue()); + total += minutes[iter]; + } + setAccessibilityText(total == 0 ? "" : describe()); + } + + /// Whether there is anything to draw. + public boolean hasData() { + return total > 0; + } + + private String describe() { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < minutes.length; iter++) { + if (minutes[iter] > 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(Lang.tr("zone {0}: {1} min", Integer.valueOf(iter + 1), + Integer.valueOf(minutes[iter]))); + } + } + return out.toString(); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(30), CN.convertToPixels(1.4f)); + } + + @Override + public void paint(Graphics g) { + if (total == 0) { + return; + } + int x = getX(); + int width = getWidth(); + int height = getHeight(); + int gap = Math.max(1, CN.convertToPixels(0.4f)); + int used = 0; + g.setAntiAliased(true); + for (int iter = 0; iter < minutes.length; iter++) { + if (minutes[iter] == 0) { + continue; + } + int start = x + used * width / total; + used += minutes[iter]; + int end = x + used * width / total; + g.setColor(COLORS[iter]); + g.fillRoundRect(start, getY(), Math.max(1, end - start - gap), height, height, height); + } + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/PulseWatch.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/PulseWatch.java new file mode 100644 index 00000000000..f39187bc2ba --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/PulseWatch.java @@ -0,0 +1,692 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.watch; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.security.Hash; +import com.codename1.security.Otp; +import com.codename1.system.Lifecycle; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableDataListener; +import com.codename1.wearable.WearableMessage; +import java.util.Date; + +/// The watch companion: four pages a member swipes between. +/// +/// - **Next** -- the next class booked, how long until it starts, and how +/// full it is. +/// - **Door** -- the rotating door code, so the phone can stay in the bag. +/// - **Week** -- progress to the next level, the weekly streak and eight +/// weeks of visits. +/// - **Coming up** -- the next few classes booked, in order. +/// - **Coaching** -- for a trainer only: the class they teach now or next, how +/// long until it starts or ends, how full it is, and their level and place +/// among the studio's coaches. The page is added when the phone first says +/// its wearer coaches, and taken away when that is withdrawn. +/// +/// This is the class `codename1.watchMain` names, so the watch build starts +/// here and carries only what it reaches -- not the phone's screens or its +/// REST clients. It talks to no server: everything it shows is what +/// [WatchBridge] replicated from the phone, read when the app starts and +/// again whenever the phone changes it. The code is worked out on the watch +/// every period, the same way the phone's pass does it. +public class PulseWatch extends Lifecycle { + private static final int LIME = 0xc6f432; + private static final int CORAL = 0xff6b4a; + private static final int TRACK = 0x2a2a30; + + private Form form; + // Made in runApp, not here: the lifecycle is constructed before the + // display exists, and a component needs its theme. + private Label title; + private Label until; + private Label when; + private Label where; + private FillBar fill; + private Label fillText; + private Label code; + private Label codeName; + private Countdown left; + private LevelRing ring; + private Label levelLine; + private Label streakLine; + private WeekBars weeks; + private Container agenda; + private PageDots dots; + private Tabs pages; + private Container coachPage; + private Label coachTitle; + private Label coachUntil; + private Label coachWhere; + private FillBar coachFill; + private Label coachFillText; + private Label coachStanding; + private long coachStarts; + private long coachEnds; + private long startsAt; + private byte[] secret; + private int period = 30; + private int digits = 8; + + @Override + public void runApp() { + form = new Form("Pulse", new BorderLayout()); + form.setUIID("SvWatchForm"); + form.getContentPane().setUIID("SvWatchForm"); + // A watch face has no room for a title bar; the eyebrows say it all. + form.getToolbar().setHidden(true); + form.setName("Watch"); + pages = new Tabs(); + pages.setUIID("SvWatchForm"); + pages.getContentPane().setUIID("SvWatchForm"); + pages.hideTabs(); + pages.setSwipeActivated(true); + pages.setName("watchPages"); + pages.addTab("Next", nextPage()); + pages.addTab("Door", doorPage()); + pages.addTab("Week", weekPage()); + pages.addTab("Coming up", agendaPage()); + dots = new PageDots(pages.getTabCount()); + pages.addSelectionListener((was, now) -> dots.select(now)); + form.add(BorderLayout.CENTER, pages); + form.add(BorderLayout.SOUTH, dots); + WearableConnection.addDataListener(new WearableDataListener() { + @Override + public void dataChanged(WearableMessage data) { + show(data); + } + + @Override + public void dataRemoved(String path) { + show(new WearableMessage(path)); + } + }); + show(stored(WatchBridge.NEXT)); + show(stored(WatchBridge.PASS)); + show(stored(WatchBridge.STATS)); + show(stored(WatchBridge.AGENDA)); + show(stored(WatchBridge.COACH)); + UITimer.timer(1000, true, form, this::tick); + form.show(); + } + + private static Container page(String name, String eyebrow) { + Container page = new Container(BoxLayout.y()); + page.setUIID("SvWatchPage"); + page.setName(name); + page.setScrollableY(true); + page.setScrollVisible(false); + page.add(new Label(eyebrow, "SvWatchEyebrow")); + return page; + } + + private Container nextPage() { + Container page = page("watchNext", "NEXT CLASS"); + title = new Label("", "SvWatchTitle"); + title.setName("watchClass"); + until = new Label("", "SvWatchBig"); + until.setName("watchUntil"); + when = new Label("", "SvWatchText"); + where = new Label("", "SvWatchText"); + fill = new FillBar(); + fillText = new Label("", "SvWatchSmall"); + return page.add(title).add(until).add(when).add(where).add(fill).add(fillText); + } + + private Container doorPage() { + Container page = page("watchDoor", "DOOR CODE"); + Container card = new Container(BoxLayout.y()); + card.setUIID("SvWatchCard"); + code = new Label("", "SvWatchCode"); + code.setName("watchCode"); + left = new Countdown(); + card.add(code).add(left); + codeName = new Label("", "SvWatchSmallCenter"); + return page.add(card).add(codeName) + .add(new Label("Hold to the reader", "SvWatchSmallCenter")); + } + + private Container weekPage() { + Container page = page("watchWeek", "YOUR WEEK"); + ring = new LevelRing(); + levelLine = new Label("", "SvWatchStat"); + levelLine.setName("watchStats"); + streakLine = new Label("", "SvWatchStreak"); + weeks = new WeekBars(); + // No caption under the bars: it fell off a round face, and the streak line + // above already says they are weeks. + return page.add(ring).add(levelLine).add(streakLine).add(weeks); + } + + private Container agendaPage() { + Container page = page("watchAgenda", "COMING UP"); + agenda = new Container(BoxLayout.y()); + return page.add(agenda); + } + + private Container coachPage() { + Container page = page("watchCoach", "COACHING"); + coachTitle = new Label("", "SvWatchTitle"); + coachTitle.setName("watchCoachClass"); + coachUntil = new Label("", "SvWatchBig"); + coachWhere = new Label("", "SvWatchText"); + coachFill = new FillBar(); + coachFillText = new Label("", "SvWatchSmall"); + coachStanding = new Label("", "SvWatchStanding"); + coachStanding.setName("watchCoachStanding"); + return page.add(coachTitle).add(coachUntil).add(coachWhere).add(coachFill) + .add(coachFillText).add(coachStanding); + } + + private static WearableMessage stored(String path) { + WearableMessage found = WearableConnection.getData(path); + return found == null ? new WearableMessage(path) : found; + } + + void show(WearableMessage data) { + String path = data.getPath(); + if (WatchBridge.NEXT.equals(path)) { + showNext(data); + } else if (WatchBridge.STATS.equals(path)) { + showStats(data); + } else if (WatchBridge.AGENDA.equals(path)) { + showAgenda(data); + } else if (WatchBridge.COACH.equals(path)) { + showCoach(data); + } else if (WatchBridge.PASS.equals(path)) { + secret = hex(data.getString("secret", "")); + period = Math.max(1, data.getInt("period", 30)); + digits = data.getInt("digits", 8); + codeName.setText(data.getString("name", "")); + tick(); + } + if (form != null) { + form.revalidate(); + } + } + + private void showNext(WearableMessage data) { + startsAt = data.getLong("startsAt", 0L); + if (startsAt == 0L) { + title.setText("No class booked"); + when.setText("Book one on your phone"); + where.setText(""); + fill.set(-1f, 0); + fillText.setText(""); + until.setText(""); + return; + } + title.setText(data.getString("name", "")); + when.setText(new SimpleDateFormat("EEE HH:mm").format(new Date(startsAt))); + where.setText(room(data.getString("room", "")) + " \u00b7 " + + data.getString("trainer", "")); + int capacity = data.getInt("capacity", 0); + int booked = data.getInt("booked", 0); + fill.set(capacity <= 0 ? -1f : Math.min(1f, booked / (float) capacity), + parse(data.getString("color", ""), LIME)); + fillText.setText(capacity <= 0 ? "" : booked + " of " + capacity + " places taken"); + tickUntil(); + } + + private void showStats(WearableMessage data) { + int points = data.getInt("points", 0); + int floor = data.getInt("floor", 0); + int next = data.getInt("next", 0); + int streak = data.getInt("streak", 0); + ring.set(points, next > floor ? (points - floor) / (float) (next - floor) : 1f, + data.getInt("level", 1)); + levelLine.setText(next > points ? (next - points) + " pts to Level " + + (data.getInt("level", 1) + 1) : "Top level"); + streakLine.setText(streak > 0 ? streak + " week streak" : "Start a streak this week"); + weeks.set(ints(data.getString("weeks", ""))); + } + + private void showAgenda(WearableMessage data) { + agenda.removeAll(); + int count = data.getInt("count", 0); + SimpleDateFormat day = new SimpleDateFormat("EEE HH:mm"); + for (int iter = 0; iter < count; iter++) { + Container row = new Container(BoxLayout.y()); + row.setUIID("SvWatchRow"); + row.setName("watchAgenda" + iter); + Label at = new Label(day.format(new Date(data.getLong("at" + iter, 0L))), + "SvWatchEyebrowMuted"); + Label name = new Label(data.getString("name" + iter, ""), "SvWatchRowTitle"); + Label room = new Label(room(data.getString("room" + iter, "")), "SvWatchSmall"); + row.add(at).add(name).add(room); + agenda.add(row); + } + if (count == 0) { + agenda.add(new Label("Nothing booked", "SvWatchText")); + } + } + + private void showCoach(WearableMessage data) { + if (!data.getBoolean("coaching", false)) { + if (coachPage != null) { + pages.removeTabAt(pages.getTabCount() - 1); + coachPage = null; + dots.setCount(pages.getTabCount()); + } + return; + } + if (coachPage == null) { + coachPage = coachPage(); + pages.addTab("Coaching", coachPage); + dots.setCount(pages.getTabCount()); + } + coachStarts = data.getLong("startsAt", 0L); + coachEnds = data.getLong("endsAt", 0L); + if (coachStarts == 0L) { + coachTitle.setText("No class today"); + coachWhere.setText(""); + coachFill.set(-1f, 0); + coachFillText.setText(""); + coachUntil.setText(""); + } else { + coachTitle.setText(data.getString("name", "")); + coachWhere.setText(room(data.getString("room", "")) + " \u00b7 " + + new SimpleDateFormat("HH:mm").format(new Date(coachStarts))); + int capacity = data.getInt("capacity", 0); + int booked = data.getInt("booked", 0); + coachFill.set(capacity <= 0 ? -1f : Math.min(1f, booked / (float) capacity), + parse(data.getString("color", ""), LIME)); + coachFillText.setText(capacity <= 0 ? "" : booked + " of " + capacity + " booked"); + } + int rank = data.getInt("rank", 0); + int level = data.getInt("level", 0); + coachStanding.setText(level <= 0 ? "" : "Level " + level + (rank > 0 ? " \u00b7 #" + + rank + " of " + data.getInt("coaches", 0) : "")); + tickCoach(); + } + + /// "in 25 min" before the class, "12 min left" during it. + private void tickCoach() { + if (coachUntil == null || coachStarts == 0L) { + return; + } + long now = System.currentTimeMillis(); + String text; + if (now < coachStarts) { + long minutes = (coachStarts - now + 59999L) / 60000L; + text = minutes < 60 ? "in " + minutes + " min" + : "in " + minutes / 60 + "h " + minutes % 60 + "m"; + } else { + long minutes = Math.max(0L, (coachEnds - now + 59999L) / 60000L); + text = minutes + " min left"; + } + if (!text.equals(coachUntil.getText())) { + coachUntil.setText(text); + } + } + + /// The phone sends "location, room"; the room is what a member walks to, + /// and the wrist has no room for both. + private static String room(String text) { + int comma = text.lastIndexOf(", "); + return comma < 0 ? text : text.substring(comma + 2); + } + + private void tick() { + tickUntil(); + tickCoach(); + if (code == null) { + return; + } + if (secret == null || secret.length == 0) { + code.setText("Open Pulse on your phone"); + return; + } + long seconds = System.currentTimeMillis() / 1000L; + long counter = seconds / period; + left.set(1f - (float) (seconds % period) / period); + String now = Otp.hotp(secret, counter, digits <= 0 ? 8 : digits, Hash.SHA256); + if (now.length() > 5) { + // Grouped in two, as a door keypad code is read out. + now = now.substring(0, now.length() / 2) + " " + now.substring(now.length() / 2); + } + if (!now.equals(code.getText())) { + code.setText(now); + code.repaint(); + } + } + + /// "in 2h 15m", counting down to the class; "Now" once it started. A day or more + /// out it rounds to days: the date line under it already says when. + private void tickUntil() { + if (until == null || startsAt == 0L) { + return; + } + long minutes = (startsAt - System.currentTimeMillis()) / 60000L; + String text; + if (minutes <= 0) { + text = "Now"; + } else if (minutes < 60) { + text = "in " + minutes + " min"; + } else if (minutes < 24 * 60) { + text = "in " + minutes / 60 + "h " + minutes % 60 + "m"; + } else { + long days = (minutes + 12 * 60) / (24 * 60); + text = days == 1 ? "in 1 day" : "in " + days + " days"; + } + if (!text.equals(until.getText())) { + until.setText(text); + } + } + + private static int[] ints(String csv) { + if (csv == null || csv.length() == 0) { + return new int[0]; + } + int count = 1; + for (int iter = 0; iter < csv.length(); iter++) { + if (csv.charAt(iter) == ',') { + count++; + } + } + int[] out = new int[count]; + int index = 0; + int value = 0; + for (int iter = 0; iter < csv.length(); iter++) { + char c = csv.charAt(iter); + if (c == ',') { + out[index++] = value; + value = 0; + } else if (c >= '0' && c <= '9') { + value = value * 10 + c - '0'; + } + } + out[index] = value; + return out; + } + + /// `#RRGGBB` to a colour, or `fallback` when it is not one. + static int parse(String color, int fallback) { + if (color == null || color.length() != 7 || color.charAt(0) != '#') { + return fallback; + } + int out = 0; + for (int iter = 1; iter < 7; iter++) { + out = (out << 4) | nibble(color.charAt(iter)); + } + return out; + } + + /// How full the next class is, in the class's own colour. + static final class FillBar extends Component { + private float fraction = -1f; + private int color = LIME; + + FillBar() { + setUIID("SvWatchBar"); + } + + void set(float fraction, int color) { + this.fraction = fraction; + this.color = color; + setVisible(fraction >= 0f); + repaint(); + } + + @Override + public void paint(Graphics g) { + if (fraction < 0f) { + return; + } + int h = getHeight() - getStyle().getVerticalPadding(); + int y = getY() + getStyle().getPaddingTop(); + int x = getX() + getStyle().getPaddingLeftNoRTL(); + int w = getWidth() - getStyle().getHorizontalPadding(); + g.setAntiAliased(true); + g.setColor(TRACK); + g.fillRoundRect(x, y, w, h, h, h); + g.setColor(color); + g.fillRoundRect(x, y, Math.max(h, (int) (w * fraction)), h, h, h); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(20), + CN.convertToPixels(1.4f) + getStyle().getVerticalPadding()); + } + } + + /// The points ring: the arc is the way from this level to the next, the + /// points in the middle. + static final class LevelRing extends Component { + private int points; + private float fraction; + private int level = 1; + + LevelRing() { + setUIID("SvWatchRing"); + } + + void set(int points, float fraction, int level) { + this.points = points; + this.fraction = Math.max(0f, Math.min(1f, fraction)); + this.level = level; + setAccessibilityText(points + " points, level " + level); + repaint(); + } + + @Override + public void paint(Graphics g) { + int size = Math.min(getWidth() - getStyle().getHorizontalPadding(), + getHeight() - getStyle().getVerticalPadding()); + int x = getX() + (getWidth() - size) / 2; + int y = getY() + getStyle().getPaddingTop(); + int stroke = Math.max(4, size / 9); + g.setAntiAliased(true); + g.setColor(TRACK); + g.fillArc(x, y, size, size, 0, 360); + g.setColor(LIME); + g.fillArc(x, y, size, size, 90, -(int) (360 * fraction)); + g.setColor(0x000000); + g.fillArc(x + stroke, y + stroke, size - 2 * stroke, size - 2 * stroke, 0, 360); + Font big = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_LARGE); + Font small = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, + Font.SIZE_SMALL); + String value = String.valueOf(points); + String caption = "LEVEL " + level; + int block = big.getHeight() + small.getHeight(); + int top = y + (size - block) / 2; + g.setColor(0xffffff); + g.setFont(big); + g.drawString(value, x + (size - big.stringWidth(value)) / 2, top); + g.setColor(LIME); + g.setFont(small); + g.drawString(caption, x + (size - small.stringWidth(caption)) / 2, + top + big.getHeight()); + } + + @Override + protected Dimension calcPreferredSize() { + int size = CN.convertToPixels(12); + return new Dimension(size + getStyle().getHorizontalPadding(), + size + getStyle().getVerticalPadding()); + } + } + + /// Eight weeks of visits as small bars, this week in lime. + static final class WeekBars extends Component { + private int[] values = new int[0]; + + WeekBars() { + setUIID("SvWatchBars"); + } + + void set(int[] values) { + this.values = values; + StringBuilder said = new StringBuilder("Visits by week:"); + for (int iter = 0; iter < values.length; iter++) { + said.append(' ').append(values[iter]); + } + setAccessibilityText(said.toString()); + repaint(); + } + + @Override + public void paint(Graphics g) { + int n = values.length; + if (n == 0) { + return; + } + int most = 1; + for (int iter = 0; iter < n; iter++) { + most = Math.max(most, values[iter]); + } + int h = getHeight() - getStyle().getVerticalPadding(); + int y = getY() + getStyle().getPaddingTop(); + int x = getX() + getStyle().getPaddingLeftNoRTL(); + int w = getWidth() - getStyle().getHorizontalPadding(); + int gap = Math.max(2, w / (n * 5)); + int bar = (w - gap * (n - 1)) / n; + g.setAntiAliased(true); + for (int iter = 0; iter < n; iter++) { + int bx = x + iter * (bar + gap); + int bh = Math.max(bar / 2, h * values[iter] / most); + g.setColor(values[iter] == 0 ? TRACK : iter == n - 1 ? LIME : 0x6b7f2a); + g.fillRoundRect(bx, y + h - bh, bar, bh, bar / 2, bar / 2); + } + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(24), + CN.convertToPixels(6) + getStyle().getVerticalPadding()); + } + } + + /// Which of the pages is showing. + static final class PageDots extends Component { + private int count; + private int selected; + + PageDots(int count) { + this.count = count; + setUIID("SvWatchDots"); + } + + void select(int index) { + selected = index; + repaint(); + } + + void setCount(int count) { + this.count = count; + selected = Math.min(selected, count - 1); + repaint(); + } + + @Override + public void paint(Graphics g) { + int d = CN.convertToPixels(1f); + int gap = d; + int total = count * d + (count - 1) * gap + d; + int x = getX() + (getWidth() - total) / 2; + int y = getY() + (getHeight() - d) / 2; + g.setAntiAliased(true); + for (int iter = 0; iter < count; iter++) { + boolean on = iter == selected; + g.setColor(on ? 0xffffff : 0x55555c); + int w = on ? 2 * d : d; + g.fillRoundRect(x, y, w, d, d, d); + x += w + gap; + } + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(10), CN.convertToPixels(2.4f)); + } + } + + /// A thin bar that empties as the code's period runs out. + static final class Countdown extends Component { + private float fraction = 1f; + + Countdown() { + setUIID("SvWatchBar"); + } + + void set(float fraction) { + if (fraction != this.fraction) { + this.fraction = fraction; + repaint(); + } + } + + @Override + public void paint(Graphics g) { + int h = getHeight() - getStyle().getVerticalPadding(); + int y = getY() + getStyle().getPaddingTop(); + int x = getX() + getStyle().getPaddingLeftNoRTL(); + int w = getWidth() - getStyle().getHorizontalPadding(); + g.setAntiAliased(true); + g.setColor(TRACK); + g.fillRoundRect(x, y, w, h, h, h); + g.setColor(fraction < 0.2f ? CORAL : LIME); + g.fillRoundRect(x, y, Math.max(h, (int) (w * fraction)), h, h, h); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(20), + CN.convertToPixels(1.2f) + getStyle().getVerticalPadding()); + } + } + + static byte[] hex(String text) { + if (text == null) { + return new byte[0]; + } + byte[] out = new byte[text.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) ((nibble(text.charAt(iter * 2)) << 4) + | nibble(text.charAt(iter * 2 + 1))); + } + return out; + } + + private static int nibble(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return 0; + } +} diff --git a/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/WatchBridge.java b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/WatchBridge.java new file mode 100644 index 00000000000..6de0b17c585 --- /dev/null +++ b/scripts/pulse/common/src/main/java/com/codenameone/examples/pulse/watch/WatchBridge.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.watch; + +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableMessage; +import com.codenameone.examples.pulse.api.BookingDto; +import com.codenameone.examples.pulse.api.MemberDto; +import com.codenameone.examples.pulse.api.PassDto; +import com.codenameone.examples.pulse.api.SessionDto; +import com.codenameone.examples.pulse.api.StatsDto; +import com.codenameone.examples.pulse.api.TrainerStatsDto; +import com.codenameone.examples.pulse.net.Api; +import com.codenameone.examples.pulse.net.Net; +import java.util.ArrayList; +import java.util.List; + +/// The phone's side of the watch app: what the watch shows, kept current. +/// +/// The watch shows the next class, the door pass, the member's week and the +/// classes coming up, and none of it needs an answer now, so both are replicated data rather than messages: each is +/// pinned at a path with [WearableConnection#putData(WearableMessage)] and the +/// watch reads it whenever it wakes, even with the phone out of reach. The +/// pass carries its secret rather than a code, because a code is good for +/// half a minute and the watch has to work out the next one itself; the +/// channel between a phone and its own paired watch is the platform's +/// encrypted one. +public final class WatchBridge { + /// The next class: `name`, `startsAt`, `room`, `trainer`, `booked`, + /// `capacity` and `color` (`#RRGGBB`); empty when none. + public static final String NEXT = "/pulse/next"; + /// The pass: `memberId`, `name`, `secret` (hex), `period`, `digits`. + public static final String PASS = "/pulse/pass"; + /// How the member is doing: `points`, `level`, `floor` and `next` (the + /// points this level starts at and the next one does), `streak` (weeks) + /// and `weeks`, visits a week as comma separated numbers, oldest first. + public static final String STATS = "/pulse/stats"; + /// The classes booked next, soonest first: `count`, then `name`, + /// `at` and `room` for each. + public static final String AGENDA = "/pulse/agenda"; + /// A coach's class, for a trainer: `name`, `startsAt`, `endsAt`, `room`, + /// `booked`, `capacity` and `color` of the class on now or next, and the + /// coach's `points`, `level`, `rank` and `coaches`. Absent for anyone who + /// does not coach, and the watch then has no coaching page. + public static final String COACH = "/pulse/coach"; + /// How many classes [#AGENDA] carries: what fits on a watch page. + static final int AGENDA_SIZE = 3; + + private WatchBridge() { + } + + /// Whether there is a watch to tell; publishing to none is harmless but + /// wasted calls. + public static boolean paired() { + return WearableConnection.isSupported() && WearableConnection.isPaired(); + } + + /// Reads the next booked class and the member's pass, and publishes both, + /// with the member's points. + public static void publish() { + if (!paired()) { + return; + } + Api.engagement().stats(Net.to(WatchBridge::stats, (status, message) -> { })); + Api.bookings().upcoming(Net.to(WatchBridge::next, (status, message) -> { })); + Api.household().members(Net.to(members -> { + String self = self(members); + if (self.length() > 0) { + Api.household().pass(self, Net.to(WatchBridge::pass, (status, message) -> { })); + } + }, (status, message) -> { })); + } + + static void next(List upcoming) { + List booked = new ArrayList(); + long now = System.currentTimeMillis(); + for (int iter = 0; upcoming != null && iter < upcoming.size(); iter++) { + BookingDto booking = upcoming.get(iter); + SessionDto session = booking.session(); + if (session == null || !"BOOKED".equals(booking.state()) || session.endsAt() < now) { + continue; + } + int at = booked.size(); + while (at > 0 && booked.get(at - 1).startsAt() > session.startsAt()) { + at--; + } + booked.add(at, session); + } + WearableMessage data = new WearableMessage(NEXT); + if (!booked.isEmpty()) { + SessionDto next = booked.get(0); + data.put("name", next.name()).put("startsAt", next.startsAt()) + .put("room", next.locationName() + ", " + next.roomName()) + .put("trainer", next.trainerName()).put("booked", next.booked()) + .put("capacity", next.capacity()) + .put("color", next.color() == null ? "" : next.color()); + } + WearableConnection.putData(data); + // The agenda starts after the class the first page already shows. + WearableMessage agenda = new WearableMessage(AGENDA); + int count = 0; + for (int iter = 1; iter < booked.size() && count < AGENDA_SIZE; iter++) { + SessionDto session = booked.get(iter); + agenda.put("name" + count, session.name()).put("at" + count, session.startsAt()) + .put("room" + count, session.locationName() + ", " + session.roomName()); + count++; + } + WearableConnection.putData(agenda.put("count", count)); + } + + /// Publishes the trainer's class on now or next and their standing; with + /// no class, only the standing. + public static void coach(SessionDto session, TrainerStatsDto stats) { + if (!paired()) { + return; + } + WearableMessage data = new WearableMessage(COACH).put("coaching", true); + if (session != null) { + data.put("name", session.name()).put("startsAt", session.startsAt()) + .put("endsAt", session.endsAt()) + .put("room", session.locationName() + ", " + session.roomName()) + .put("booked", session.booked()).put("capacity", session.capacity()) + .put("color", session.color() == null ? "" : session.color()); + } + if (stats != null) { + data.put("points", stats.points()).put("level", stats.level()) + .put("rank", stats.rank()) + .put("coaches", stats.leaderboard() == null ? 0 : stats.leaderboard().size()); + } + WearableConnection.putData(data); + } + + /// Takes everything off the watch: whoever signs in next has their own + /// pass, classes and numbers, and nobody's are left on a wrist after + /// signing out. + public static void clear() { + if (!WearableConnection.isSupported()) { + return; + } + WearableConnection.removeData(NEXT); + WearableConnection.removeData(PASS); + WearableConnection.removeData(STATS); + WearableConnection.removeData(AGENDA); + WearableConnection.removeData(COACH); + } + + static void stats(StatsDto stats) { + if (stats == null) { + return; + } + StringBuilder weeks = new StringBuilder(); + List visits = stats.weeklyVisits(); + for (int iter = 0; visits != null && iter < visits.size(); iter++) { + if (iter > 0) { + weeks.append(','); + } + weeks.append(visits.get(iter).intValue()); + } + WearableConnection.putData(new WearableMessage(STATS).put("points", stats.points()) + .put("level", stats.level()).put("floor", stats.levelFloor()) + .put("next", stats.nextLevel()).put("streak", stats.weekStreak()) + .put("weeks", weeks.toString())); + } + + static void pass(PassDto pass) { + if (pass == null) { + return; + } + WearableConnection.putData(new WearableMessage(PASS).put("memberId", pass.memberId()) + .put("name", pass.memberName()).put("secret", pass.secret()) + .put("period", pass.periodSeconds()).put("digits", pass.digits())); + } + + private static String self(List members) { + for (int iter = 0; members != null && iter < members.size(); iter++) { + if ("SELF".equals(members.get(iter).relation())) { + return members.get(iter).id(); + } + } + return members == null || members.isEmpty() ? "" : members.get(0).id(); + } +} diff --git a/scripts/pulse/common/src/main/l10n/Bundle.properties b/scripts/pulse/common/src/main/l10n/Bundle.properties new file mode 100644 index 00000000000..c07a695976e --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle.properties @@ -0,0 +1,3 @@ +# Workaround for the simulator AutoLocalizationBundle @im fabrication crash +# in Codename One <= 7.0.236. Once the framework fix ships, this file can be removed. +@im= diff --git a/scripts/pulse/common/src/main/l10n/Bundle_de.properties b/scripts/pulse/common/src/main/l10n/Bundle_de.properties new file mode 100644 index 00000000000..a1cd5d49154 --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle_de.properties @@ -0,0 +1,672 @@ +# Pulse in German. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=false +language.name=Deutsch + +Dashboard=\u00dcbersicht +Today=Heute +Verified=Best\u00e4tigt +This\ week=Diese Woche +Accept=Annehmen +Decline=Ablehnen +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Der Server ist nicht erreichbar. Pr\u00fcfe deine Verbindung. +Please\ sign\ in\ again.=Bitte melde dich erneut an. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Dein Konto darf das nicht. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Zu viele Versuche. Warte kurz und versuche es erneut. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Zu viele Versuche. Warte eine Minute und versuche es erneut. +Something\ went\ wrong\ ({0}).\ Try\ again.=Etwas ist schiefgelaufen ({0}). Versuche es erneut. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Diese E-Mail und dieses Passwort passen zu keinem Konto. +Signing\ in\ failed.\ Try\ again.=Anmeldung fehlgeschlagen. Versuche es erneut. +Home=Zuhause +Payment=Zahlung +Cash=Bar +Card=Karte +Message=Nachricht +Not\ now=Nicht jetzt +Switch\ to=Wechseln zu +Settings=Einstellungen +Sign\ out=Abmelden +No\ connection=Keine Verbindung +The\ server\ at\ {0}\ did\ not\ answer.=Der Server unter {0} hat nicht geantwortet. +Try\ again=Erneut versuchen +Server\ address=Serveradresse +Create\ an\ account=Konto erstellen +Name=Name +E-mail=E-Mail +Mobile\ number=Handynummer +Password=Passwort +At\ least\ 8\ characters=Mindestens 8 Zeichen +Create\ account=Konto erstellen +Server=Server +Address=Adresse +Save=Speichern +Account=Konto +Verify=Best\u00e4tigen +Pay\ with=Bezahlen mit +Notifications=Benachrichtigungen +Receipts=Belege +Offers=Angebote +Appearance=Darstellung +Language=Sprache +Theme=Design +System=System +Light=Hell +Dark=Dunkel +Distances=Entfernungen +Kilometres=Kilometer +Miles=Meilen +About=Info +Terms\ of\ service=Nutzungsbedingungen +Privacy\ policy=Datenschutzerkl\u00e4rung +Sign\ in=Anmelden +Enter\ your\ e-mail\ and\ password.=Gib deine E-Mail und dein Passwort ein. +{0}\ ft={0} ft +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} Min. +Verify\ your\ number=Nummer best\u00e4tigen +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Wir haben einen sechsstelligen Code an dein Telefon gesendet. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Wir haben einen sechsstelligen Code an {0} gesendet. +Code=Code +Send\ a\ new\ code=Neuen Code senden + +# Onboarding applications, payments, the admin console and the account. +A\ sentence\ the\ record\ will\ keep=Ein Satz, der im Protokoll bleibt +About\ you=\u00dcber dich +Add\ a\ card=Karte hinzuf\u00fcgen +Back=Zur\u00fcck +Cancelled=Storniert +Card\ number=Kartennummer +Change\ password=Passwort \u00e4ndern +Change\ the\ password\ you\ sign\ in\ with=\u00c4ndere das Passwort f\u00fcr die Anmeldung +Colour=Farbe +Current\ password=Aktuelles Passwort +Date\ of\ birth=Geburtsdatum +Default=Standard +Delete\ account=Konto l\u00f6schen +Delete\ my\ account=Mein Konto l\u00f6schen +Delete\ your\ account?=Dein Konto l\u00f6schen? +Everyone=Alle +Expires=L\u00e4uft ab +Expires\ {0}=L\u00e4uft ab {0} +History=Verlauf +I\ have\ finished=Ich bin fertig +Keep\ my\ account=Mein Konto behalten +Name\ on\ the\ card=Name auf der Karte +New\ password=Neues Passwort +New\ password,\ again=Neues Passwort, noch einmal +Open\ the\ secure\ page=Sichere Seite \u00f6ffnen +Paid=Bezahlt +Profile=Profil +Reason=Grund +Remove\ this\ card=Diese Karte entfernen +Save\ card=Karte speichern +Say\ why,\ in\ a\ few\ words.=Sag in wenigen Worten, warum. +Security\ code=Sicherheitscode +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Tippe auf eine Karte, damit sie zahlt, sofern du nichts anderes w\u00e4hlst. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Das ist keine Kartennummer. Pr\u00fcfe die Ziffern. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=Der Sicherheitscode sind die drei oder vier Ziffern auf der Karte. +The\ two\ new\ passwords\ are\ not\ the\ same.=Die beiden neuen Passw\u00f6rter stimmen nicht \u00fcberein. +This\ month=Diesen Monat +Wallet=Wallet +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Gib das Ablaufdatum als Monat und Jahr ein: 04/28. +Your\ card\ goes\ straight\ to\ the\ bank=Deine Karte geht direkt an die Bank +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Deine Karten und welche standardm\u00e4\u00dfig zahlt +Your\ password\ was\ changed=Dein Passwort wurde ge\u00e4ndert +Usage\ statistics=Nutzungsstatistiken +Help\ improve\ Pulse=Hilf mit, Pulse zu verbessern +No\ personal\ details=Keine pers\u00f6nlichen Daten +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Niemals dein Name, deine E-Mail-Adresse, deine Telefonnummer, deine Orte oder deine Kartendaten +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Jederzeit in den Einstellungen ein- oder ausschalten +Share\ usage\ statistics=Nutzungsstatistiken teilen + +# Ordering, kitchens and deliveries +Active=Aktiv +Add=Hinzuf\u00fcgen +Description=Beschreibung +Free=Kostenlos +Hours=Zeiten +Menu=Speisekarte +New=Neu +Price=Preis +Privacy=Datenschutz +Register=Registrieren +Remove=Entfernen +The\ address\ of\ the\ Pulse\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=Die Adresse des Pulse-Servers, mit dem diese App spricht. Eine \u00c4nderung meldet dich ab. +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Dies ist ein Entwicklungsserver, daher wurde keine SMS gesendet. Dein Code ist {0}. +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Pulse\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=Die Seite des Zahlungsanbieters \u00f6ffnet sich im Browser. Pulse sieht die Nummer nie. Komm hierher zur\u00fcck, wenn du fertig bist. +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Dieser Server nimmt keine echten Zahlungen an. Nutze die Testkarte 4242 4242 4242 4242 mit einem beliebigen k\u00fcnftigen Datum und drei beliebigen Ziffern. +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Teile, welche Bildschirme und Funktionen genutzt werden. Niemals deinen Namen, deine Orte oder deine Zahlungsdaten. +Pulse\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Pulse kann z\u00e4hlen, welche Bildschirme ge\u00f6ffnet und welche Funktionen genutzt werden, damit wir sehen, was funktioniert, und beheben k\u00f6nnen, was nicht funktioniert. +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Sind die Nutzungsstatistiken eingeschaltet, z\u00e4hlt die App au\u00dferdem, welche Bildschirme ge\u00f6ffnet und welche Funktionen genutzt werden, und meldet das unter einer f\u00fcr dieses Ger\u00e4t erfundenen Kennung an den Analysedienst von Codename One. Dein Name, deine E-Mail-Adresse, deine Telefonnummer, deine Orte und deine Kartendaten geh\u00f6ren nie dazu, und wenn du sie in den Einstellungen ausschaltest, h\u00f6rt es auf. +The\ server\ changed.\ Sign\ in\ again.=Der Server hat sich ge\u00e4ndert. Melde dich erneut an. + +First\ class=Erster Kurs +Ten\ classes=Zehn Kurse +Fifty\ classes=F\u00fcnfzig Kurse +Four-week\ streak=Vier Wochen in Folge +Twelve-week\ streak=Zw\u00f6lf Wochen in Folge +Heart\ tracked=Herz erfasst +Strapped\ in=Brustgurt angelegt +Zone\ 5=Zone 5 +Ten\ hours=Zehn Kurse Training +Come\ to\ your\ first\ class.=Komm zu deinem ersten Kurs. +Come\ to\ ten\ classes.=Komm zu zehn Kursen. +Come\ to\ fifty\ classes.=Komm zu f\u00fcnfzig Kursen. +Train\ every\ week\ for\ four\ weeks\ running.=Trainiere vier Wochen lang jede Woche. +Train\ every\ week\ for\ twelve\ weeks\ running.=Trainiere zw\u00f6lf Wochen lang jede Woche. +Record\ a\ workout\ with\ your\ heart\ rate.=Zeichne ein Training mit deiner Herzfrequenz auf. +Wear\ a\ heart-rate\ strap\ or\ a\ watch\ in\ a\ class.=Trage im Kurs einen Brustgurt oder eine Uhr. +Spend\ a\ minute\ flat\ out,\ in\ zone\ 5.=Verbringe eine Minute am Limit, in Zone 5. +Record\ ten\ hours\ of\ workouts.=Zeichne zehn Kurse Training auf. +{0},\ not\ earned\ yet={0}, noch nicht erreicht +Earned=Erreicht +Not\ earned\ yet=Noch nicht erreicht +YYYY-MM-DD=JJJJ-MM-TT +zone\ {0}\:\ {1}\ min=Zone {0}: {1} Min. +Your\ studio\ in\ your\ pocket.=Dein Studio in deiner Tasche. +Book\ classes\ for\ you\ and\ your\ family\ in\ two\ taps=Buche Kurse f\u00fcr dich und deine Familie mit zwei Tipps +Walk\ in\ with\ a\ pass\ on\ your\ phone\ or\ your\ watch=Komm mit dem Pass auf Handy oder Uhr herein +Earn\ points\ and\ badges\ for\ every\ class\ you\ show\ up\ to=Sammle Punkte und Abzeichen f\u00fcr jeden Kurs, zu dem du kommst +Walk-in=Spontanbesuch +Just\ in=Gerade eingetroffen +Checked\ in=Eingecheckt +In=Drin +Turned\ away=Abgewiesen +Monthly=Monatlich +Annual=J\u00e4hrlich +Class\ pack=Kartenpaket +Drop-in=Einzelbesuch +New\ plan=Neuer Tarif +Retired=Eingestellt +Unlimited\ monthly=Monatlich unbegrenzt +What\ the\ plan\ includes=Was der Tarif enth\u00e4lt +Kind=Art +Months\ per\ period=Monate pro Laufzeit +Classes\ included\ (0\ for\ unlimited)=Enthaltene Kurse (0 f\u00fcr unbegrenzt) +Off-peak\ only=Nur au\u00dferhalb der Sto\u00dfzeiten +On\ sale=Im Verkauf +A\ plan\ needs\ a\ name\ and\ a\ price.=Ein Tarif braucht einen Namen und einen Preis. +Plan\ saved=Tarif gespeichert +{0}\ sent,\ {1}\ opened,\ {2}\ booked={0} gesendet, {1} ge\u00f6ffnet, {2} gebucht +No\ campaigns\ sent\ yet.=Noch keine Kampagnen gesendet. +New\ campaign=Neue Kampagne +Class\ regulars=Stammg\u00e4ste des Kurses +Lapsed\ members=Inaktive Mitglieder +Every\ member=Alle Mitglieder +Audience=Zielgruppe +Lapsed=Inaktiv +Name,\ for\ you=Name, f\u00fcr dich +Spin\ Tuesday\ push=Spinning-Dienstag-Push +Title=Titel +A\ place\ is\ waiting=Ein Platz wartet auf dich +Tuesday's\ spin\ has\ room.\ Come\ ride\ with\ us.=Im Spinning am Dienstag ist Platz. Komm mit uns fahren. +Places\ left\ in\ {0}=Freie Pl\u00e4tze in {0} +{0}\ on\ {1}\ has\ room\ for\ you.\ Tap\ to\ book.={0} am {1} hat Platz f\u00fcr dich. Tippe zum Buchen. +Win\ back=Zur\u00fcckgewinnen +We\ miss\ you=Wir vermissen dich +Your\ next\ class\ is\ on\ us.\ Tap\ to\ see\ the\ timetable.=Dein n\u00e4chster Kurs geht auf uns. Tippe f\u00fcr den Stundenplan. +Send=Senden +Sent\ to\ {0}\ members=An {0} Mitglieder gesendet +Schedule\ a\ class=Kurs einplanen +Nothing\ on\ the\ timetable\ this\ week.=Diese Woche steht nichts im Stundenplan. +When=Wann +Where=Wo +Substitute=Vertretung +{0}\ takes\ the\ class={0} \u00fcbernimmt den Kurs +Cancel\ class=Kurs absagen +Everyone\ booked\ is\ told,\ with\ your\ reason,\ and\ their\ credit\ is\ returned.=Alle Gebuchten werden mit deiner Begr\u00fcndung informiert und erhalten ihr Guthaben zur\u00fcck. +Class\ cancelled=Kurs abgesagt +Add\ a\ class\ template,\ a\ room\ and\ a\ trainer\ first.=Lege zuerst eine Kursvorlage, einen Raum und einen Trainer an. +Room=Raum +Places\ (blank\ for\ the\ template's)=Pl\u00e4tze (leer = wie in der Vorlage) +Weeks=Wochen +{0}\ classes\ scheduled={0} Kurse eingeplant +Members=Mitglieder +Places\ taken=Belegte Pl\u00e4tze +At\ risk=Gef\u00e4hrdet +{0}\ joined\ and\ {1}\ left\ in\ 30\ days;\ {2}\ check-ins\ this\ week,\ {3}\ invoices\ past\ due.={0} neu und {1} gegangen in 30 Tagen; {2} Check-ins diese Woche, {3} \u00fcberf\u00e4llige Rechnungen. +{0}\ in\ the\ last\ 30\ days={0} in den letzten 30 Tagen +Popular\ classes=Beliebte Kurse +No\ bookings\ in\ the\ last\ month.=Keine Buchungen im letzten Monat. +Under-filled\ this\ week=Schwach gebucht diese Woche +Every\ class\ is\ filling\ up.=Alle Kurse f\u00fcllen sich. +Win\ back\ {0}\ lapsing\ members={0} abwandernde Mitglieder zur\u00fcckgewinnen +Win\ back\ a\ lapsing\ member=Ein abwanderndes Mitglied zur\u00fcckgewinnen +Revenue=Umsatz +{0}\ classes\ this\ week={0} Kurse diese Woche +Roles\ saved=Rollen gespeichert +Offered=Angeboten +No\ shifts\ on\ the\ rota.=Keine Schichten im Dienstplan. +Add\ a\ shift=Schicht hinzuf\u00fcgen +Nobody\ on\ the\ staff\ yet.=Noch niemand im Team. +Who=Wer +Duty=Aufgabe +Starts=Beginn +Shift\ added=Schicht hinzugef\u00fcgt +New\ template=Neue Vorlage +{0}\ places={0} Pl\u00e4tze +intensity\ {0}\ of\ 5=Intensit\u00e4t {0} von 5 +Spin\ 45=Spinning 45 +What\ the\ class\ is\ like=Wie der Kurs ist +Category=Kategorie +Cardio=Cardio +Minutes=Minuten +minutes=Minuten +Places=Pl\u00e4tze +Intensity,\ 1\ to\ 5=Intensit\u00e4t, 1 bis 5 +Youngest\ age\ (0\ for\ any)=Mindestalter (0 f\u00fcr beliebig) +Oldest\ age\ (0\ for\ any)=H\u00f6chstalter (0 f\u00fcr beliebig) +Bookable=Buchbar +A\ template\ needs\ a\ name.=Eine Vorlage braucht einen Namen. +Template\ saved=Vorlage gespeichert +Workouts=Trainings +Share\ with\ friends=Mit Freunden teilen +RECORDING=AUFNAHME +ON\ NOW=L\u00c4UFT JETZT +Wear\ a\ strap\ or\ your\ watch\ and\ this\ fills\ in.\ Worn\ sensors\ earn\ a\ bonus.=Trage einen Brustgurt oder deine Uhr, dann f\u00fcllt sich das aus. Getragene Sensoren bringen einen Bonus. +Record\ it\ to\ log\ the\ workout\ and\ earn\ points\ for\ the\ class.=Zeichne auf, um das Training zu speichern und Punkte f\u00fcr den Kurs zu sammeln. +Record\ this\ class=Diesen Kurs aufzeichnen +Workout\ saved\:\ +{0}\ points=Training gespeichert: +{0} Punkte +Workout\ saved=Training gespeichert +End\ workout=Training beenden +Health\ data\ could\ not\ be\ read=Gesundheitsdaten konnten nicht gelesen werden +{0}\ workouts\ added={0} Trainings hinzugef\u00fcgt +Recent=Zuletzt +No\ workouts\ yet.=Noch keine Trainings. +Measured\ by\ a\ strap=Mit Brustgurt gemessen +Measured\ by\ a\ watch=Mit Uhr gemessen +{0}\ points={0} Punkte +Workout=Training +PULSE\ MEMBER=PULSE-MITGLIED +Due=F\u00e4llig +Cancel\ at\ the\ end\ of\ this\ period=Zum Ende dieser Laufzeit k\u00fcndigen +Friends=Freunde +NEXT\ CLASS=N\u00c4CHSTER KURS +Nothing\ booked\ yet=Noch nichts gebucht +Pick\ a\ class\ and\ your\ pass\ and\ reminder\ are\ ready.=W\u00e4hle einen Kurs, und dein Pass und die Erinnerung sind bereit. +Find\ a\ class=Kurs finden +for\ {0}=f\u00fcr {0} +Show\ my\ pass=Meinen Pass zeigen +Details=Details +Top\ level=H\u00f6chstes Level +{0}\ week\ streak={0} Wochen in Folge +visits=Besuche +kcal=kcal +Badges\ \ {0}/{1}=Abzeichen {0}/{1} +Wear\ a\ heart\ rate\ strap\ or\ your\ watch\ to\ class\:\ +25\ points\ a\ class\ and\ the\ Strapped\ in\ badge.=Trage zum Kurs einen Brustgurt oder deine Uhr: +25 Punkte pro Kurs und das Abzeichen \u201eBrustgurt angelegt\u201c. +Morning,\ {0}=Guten Morgen, {0} +Afternoon,\ {0}=Guten Tag, {0} +Evening,\ {0}=Guten Abend, {0} +On\ now=L\u00e4uft jetzt +In\ {0}\ min=In {0} Min. +In\ {0}\ h=In {0} Std. +In\ {0}\ days=In {0} Tagen +{0}\ left={0} frei +{0}\ waiting={0} warten +Cancel\ this\ booking=Diese Buchung stornieren +No\ friends\ yet.\ Training\ is\ better\ together.=Noch keine Freunde. Gemeinsam trainiert es sich besser. +Feed=Aktivit\u00e4ten +Your\ friends'\ classes\ and\ workouts\ appear\ here.=Die Kurse und Trainings deiner Freunde erscheinen hier. +Add\ a\ friend=Freund hinzuf\u00fcgen +Their\ email=Seine/ihre E-Mail +Send\ request=Anfrage senden +Enter\ their\ email\ address.=Gib die E-Mail-Adresse ein. +Request\ sent=Anfrage gesendet +Wants\ to\ be\ friends=M\u00f6chte befreundet sein +Asked,\ waiting\ for\ them=Angefragt, wartet auf Antwort +Getting\ started=Gerade angefangen +Invite=Einladen +Remove\ friend=Freund entfernen +You\ did\ {1}=Du hast {1} gemacht +{0}\ did\ {1}={0} hat {1} gemacht +{0}\ kudos={0} Kudos +Invite\ {0}={0} einladen +Which\ class?=Welcher Kurs? +You\ are\ going=Du kommst +{0}\ is\ invited={0} ist eingeladen +Pass\ for\ {0}=Pass f\u00fcr {0} +Waiver\ needed\ before\ class=Einverst\u00e4ndniserkl\u00e4rung vor des Kurses n\u00f6tig +Queue=Warteliste +MM/YY=MM/JJ +Choose\ how\ to\ pay.=W\u00e4hle die Zahlungsart. +Pay\ at\ the\ front\ desk=An der Rezeption zahlen +Live\ view=Live-Ansicht +Medical\:\ {0}=Medizinisch: {0} +First\ visit=Erster Besuch +{0}\ waiting\ for\ a\ place={0} warten auf einen Platz +Nobody\ booked\ yet.=Noch niemand gebucht. +Marked\ present=Als anwesend markiert +Marked\ absent=Als abwesend markiert +Heart\ rate\ zones,\ live.\ Zone\ 5\ is\ flat\ out.=Herzfrequenzzonen, live. Zone 5 ist am Limit. +Nobody\ in\ yet=Noch niemand da +Members\ appear\ here\ once\ they\ are\ booked.=Mitglieder erscheinen hier, sobald sie gebucht haben. +Zone\ {0}=Zone {0} +No\ reading=Kein Messwert +No\ classes\ this\ week=Keine Kurse diese Woche +Classes\ the\ studio\ gives\ you\ appear\ here.=Kurse, die dir das Studio zuteilt, erscheinen hier. +UP\ NEXT=ALS N\u00c4CHSTES +{0}\ of\ {1}\ booked={0} von {1} gebucht +Open\ live\ view=Live-Ansicht \u00f6ffnen +Tomorrow=Morgen +No\ class\ today=Heute kein Kurs +Mine=Meine +Withdraw\ offer=Angebot zur\u00fcckziehen +Offer\ swap=Tausch anbieten +Offered\ to\ the\ team=Dem Team angeboten +Offer\ withdrawn=Angebot zur\u00fcckgezogen +No\ shifts\ to\ come.=Keine kommenden Schichten. +Offered\ by\ others=Von anderen angeboten +Take=\u00dcbernehmen +The\ shift\ is\ yours=Die Schicht geh\u00f6rt dir +Nothing\ offered\ right\ now.=Im Moment wird nichts angeboten. +A\ frozen\ plan\ is\ not\ billed\ and\ its\ classes\ cannot\ be\ booked.\ It\ starts\ again\ by\ itself\ on\ the\ last\ day.=Ein pausierter Tarif wird nicht abgerechnet und seine Kurse k\u00f6nnen nicht gebucht werden. Am letzten Tag l\u00e4uft er von selbst wieder an. +A\ sample\ gym\ and\ studio\ app\ built\ with\ Codename\ One=Eine Beispiel-App f\u00fcr Fitnessstudios, gebaut mit Codename One +Add\ a\ child=Kind hinzuf\u00fcgen +Add\ to\ calendar=Zum Kalender hinzuf\u00fcgen +Added\ to\ your\ calendar=Zu deinem Kalender hinzugef\u00fcgt +After\ every\ payment=Nach jeder Zahlung +Age\ {0}=Alter {0} +Ages\ {0}\ to\ {1}=Alter {0} bis {1} +Allergies,\ asthma,\ an\ injury\ -\ optional=Allergien, Asthma, eine Verletzung - optional +Amount=Betrag +An\ hour\ before\ every\ class\ you\ booked=Eine Kurs vor jedem gebuchten Kurs +Anything\ the\ coach\ should\ know=Alles, was der Trainer wissen sollte +Away=Abwesend +Away\ periods=Abwesenheiten +Billing\ paused=Abrechnung pausiert +Book=Buchen +Booked=Gebucht +Bookings=Buchungen +Calendar\ access\ was\ not\ allowed=Kalenderzugriff wurde nicht erlaubt +Campaigns=Kampagnen +Cancelled\ by\ the\ studio=Vom Studio abgesagt +Challenges,\ offers\ and\ news=Challenges, Angebote und Neuigkeiten +Check\ in=Einchecken +Class=Kurs +Class\ is\ full.\ You\ are\ number\ {0}\ in\ the\ queue=Der Kurs ist voll. Du bist Nummer {0} in der Warteliste +Class\ reminders=Kurserinnerungen +Class\ templates=Kursvorlagen +Coaching=Coaching +Coming\ up=Demn\u00e4chst +Could\ not\ add\ it\ to\ your\ calendar=Konnte nicht zu deinem Kalender hinzugef\u00fcgt werden +Day\ pass=Tageskarte +Desk=Rezeption +Find\ a\ member=Mitglied suchen +Freeze=Pausieren +Freeze\ the\ plan\ for\ these\ dates=Den Tarif f\u00fcr diese Tage pausieren +From=Von +Front\ desk=Rezeption +Frozen=Pausiert +Full,\ {0}\ waiting=Voll, {0} warten +Going\ somewhere?\ Tell\ us\ and\ we\ will\ not\ nag\ you.=Verreist du? Sag es uns, dann nerven wir dich nicht. +Guest=Gast +Hold\ the\ fob\ near\ the\ top=Halte den Schl\u00fcsselanh\u00e4nger an die Oberseite +Household=Haushalt +I'll\ be\ away=Ich bin weg +Inbox=Posteingang +Injured=Verletzt +Injury=Verletzung +Invoices=Rechnungen +Join\ for\ {0}=Beitreten f\u00fcr {0} +Kids'\ classes\ are\ by\ age,\ so\ the\ timetable\ shows\ them\ the\ classes\ they\ can\ join.\ You\ sign\ their\ waiver\ next.=Kinderkurse richten sich nach dem Alter, deshalb zeigt des Kursesnplan nur passende Kurse. Als N\u00e4chstes unterschreibst du die Einverst\u00e4ndniserkl\u00e4rung. +Legal=Rechtliches +Let\ them\ in=Einlassen +Live\ class=Live-Kurs +Lookup=Suchen +Lost\ your\ phone\ or\ fob?\ Replace\ the\ pass=Handy oder Schl\u00fcsselanh\u00e4nger verloren? Pass ersetzen +Manage\ membership=Mitgliedschaft verwalten +Member=Mitglied +Membership=Mitgliedschaft +Membership\ pass=Mitgliedspass +My\ bookings=Meine Buchungen +My\ classes=Meine Kurse +Name\ or\ e-mail=Name oder E-Mail +Needed\ for\ reminders\ and\ the\ front\ desk=F\u00fcr Erinnerungen und die Rezeption n\u00f6tig +News\ from\ the\ studio\ shows\ up\ here.=Neuigkeiten vom Studio erscheinen hier. +No\ classes=Keine Kurse +No\ classes\ attended\ yet.=Noch keine Kurse besucht. +No\ invoices\ yet.=Noch keine Rechnungen. +No\ plan\ yet.\ Pick\ one\ to\ start\ booking\ classes.=Noch kein Tarif. W\u00e4hle einen, um Kurse zu buchen. +No\ time\ away=Keine Abwesenheit +Nobody\ in\ the\ household\ is\ on\ a\ plan\ yet.=Niemand im Haushalt hat bisher einen Tarif. +Not\ admitted=Nicht eingelassen +Nothing\ booked.=Nichts gebucht. +Nothing\ on\ this\ day\ for\ {0}.=An diesem Tag nichts f\u00fcr {0}. +Nothing\ on\ this\ day.=An diesem Tag nichts. +Nothing\ yet=Noch nichts +Nudges=Erinnerungen +Or\ type\ the\ pass=Oder Pass eintippen +Other=Sonstiges +Owner=Inhaber +Owner\ mode=Inhabermodus +Paid\ by=Bezahlt mit +Paid.\ Thank\ you=Bezahlt. Danke +Pass=Pass +Pause\ billing=Abrechnung pausieren +Pay\ now=Jetzt bezahlen +Payment\ due=Zahlung f\u00e4llig +Plan=Tarif +Plans=Tarife +Plans\ and\ pricing=Tarife und Preise +Pulse=Pulse +Pulse\ is\ a\ sample.\ No\ real\ classes\ are\ booked,\ no\ studio\ is\ opened\ to\ you\ and\ no\ money\ changes\ hands.=Pulse ist ein Beispiel. Es werden keine echten Kurse gebucht, kein Studio \u00f6ffnet sich f\u00fcr dich und es flie\u00dft kein Geld. +Queue\ \#{0}=Warteliste #{0} +Roster=Teilnehmerliste +Scan\ pass=Pass scannen +Schedule=Plan +See\ plans=Tarife ansehen +Shifts=Schichten +Sign\ waiver=Einverst\u00e4ndniserkl\u00e4rung unterschreiben +Some\ classes\ are\ for\ an\ age\ band,\ so\ the\ timetable\ needs\ to\ know.\ Children\ are\ added\ later,\ from\ Household.=Manche Kurse sind f\u00fcr eine Altersgruppe, deshalb muss des Kursesnplan es wissen. Kinder f\u00fcgst du sp\u00e4ter unter Haushalt hinzu. +Staff=Team +Starting=Beginnt +Studio=Studio +Studio\ owner=Studioinhaber +Tap\ fob=Schl\u00fcsselanh\u00e4nger antippen +The\ fob\ could\ not\ be\ read=Der Schl\u00fcsselanh\u00e4nger konnte nicht gelesen werden +The\ scan\ failed.\ Try\ again=Der Scan ist fehlgeschlagen. Versuche es erneut +Their\ first\ and\ last\ name=Vor- und Nachname +This\ device\ cannot\ read\ NFC=Dieses Ger\u00e4t kann kein NFC lesen +This\ device\ has\ no\ calendar\ to\ add\ to=Dieses Ger\u00e4t hat keinen Kalender zum Hinzuf\u00fcgen +This\ device\ has\ no\ scanner.\ Type\ the\ pass\ instead=Dieses Ger\u00e4t hat keinen Scanner. Tippe den Pass stattdessen ein +Time\ away=Abwesenheit +Timetable=Stundenplan +Trainer=Trainer +Trainer\ mode=Trainermodus +Travel=Reise +Travelling=Auf Reisen +Travelling\ or\ injured?\ No\ nudges,\ and\ billing\ can\ pause=Auf Reisen oder verletzt? Keine Erinnerungen, und die Abrechnung kann pausieren +Trial=Probetraining +Unfreeze=Fortsetzen +Unreachable=Nicht erreichbar +Until=Bis +Visit=Besuch +Visits\ this\ month=Besuche diesen Monat +Waiting\ for\ the\ next\ member.=Warte auf das n\u00e4chste Mitglied. +Welcome\ in=Willkommen +Welcome\ to\ Pulse=Willkommen bei Pulse +What\ your\ coaches\ will\ call\ you=Wie dich deine Trainer nennen +Why=Warum +With\ {0}=Mit {0} +Your\ bookings,\ visits,\ household\ and\ settings\ are\ kept\ with\ your\ account\ on\ the\ server\ you\ signed\ in\ to.\ Your\ membership\ pass\ is\ a\ code\ this\ phone\ works\ out\ from\ a\ secret\ that\ server\ gave\ it,\ and\ nothing\ else\ about\ you\ is\ in\ it.=Deine Buchungen, Besuche, dein Haushalt und deine Einstellungen werden mit deinem Konto auf dem Server gespeichert, bei dem du angemeldet bist. Dein Mitgliedspass ist ein Code, den dieses Handy aus einem Geheimnis berechnet, das der Server ihm gegeben hat, und enth\u00e4lt sonst nichts \u00fcber dich. +Your\ name=Dein Name +Your\ old\ pass\ and\ fob\ no\ longer\ open\ the\ door=Dein alter Pass und Schl\u00fcsselanh\u00e4nger \u00f6ffnen die T\u00fcr nicht mehr +Your\ plan\ ends\ at\ the\ end\ of\ this\ period=Dein Tarif endet am Ende dieser Laufzeit +Your\ plan,\ freezes\ and\ invoices=Dein Tarif, Pausen und Rechnungen +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Classes\ you\ attended\ stay\ in\ the\ studio's\ records,\ under\ the\ email\ address\ you\ signed\ in\ with.=Deine Anmeldung, dein Profil, deine gespeicherten Karten und deine Einstellungen werden endg\u00fcltig gel\u00f6scht. Besuchte Kurse bleiben in den Unterlagen des Studios, unter der E-Mail-Adresse, mit der du dich angemeldet hast. +ends\ {0}=endet {0} +for\ the\ follow-up=f\u00fcr die Nachfrage +frozen\ until\ {0}=pausiert bis {0} +renews\ {0}=verl\u00e4ngert sich {0} +{0}\ classes\ left=Noch {0} Kurse +{0}\ is\ in={0} ist drin +{0}\ of\ {1}\ places\ left={0} von {1} Pl\u00e4tzen frei +{0}\ starts\ in\ an\ hour.\ See\ you\ there,\ {1}={0} beginnt in einer Kurs. Bis gleich, {1} +{0}\ weeks\ ago=Vor {0} Wochen +1\ friend=1 Freund +{0}\ friends={0} Freunde +Wear\ a\ watch\ or\ a\ strap\ to\ earn\ bonus\ points.=Ein Training mit Uhr oder Brustgurt bringt Bonuspunkte. +Check-ins=Check-ins +Walk-ins=Laufkunden +Import\ from\ Health=Aus Health importieren +Joined=Beigetreten +Left=Gegangen +Key\ fob=Schl\u00fcsselanh\u00e4nger +Pass\ number=Passnummer +Past\ due=\u00dcberf\u00e4llig +Monthly\ recurring=Monatlich wiederkehrend +Show\ this\ at\ the\ front\ desk.\ It\ refreshes\ every\ {0}\ seconds.=Zeig das an der Rezeption. Es erneuert sich alle {0} Sekunden. +Takings=Einnahmen +Visits\ /\ wk=Besuche / Wo. +Visits\ each\ week=Besuche pro Woche +{0}\ booked={0} gebucht +{0}\ bpm\ avg=\u00d8 {0} bpm +{0}\ more\ to\ level\ {1}=Noch {0} bis Level {1} +Jan=Jan. +Feb=Feb. +Mar=M\u00e4rz +Apr=Apr. +May=Mai +Jun=Juni +Jul=Juli +Aug=Aug. +Sep=Sep. +Oct=Okt. +Nov=Nov. +Dec=Dez. + +# The owner's insights, members and billing, and the trainer's month. +Insights=Einblicke +Billing=Abrechnung +Busy\ hours,\ growth,\ classes\ and\ coaches=Sto\u00dfzeiten, Wachstum, Kurse und Coaches +{0}\ at\ risk={0} gef\u00e4hrdet +{0}\ invoices\ past\ due={0} Rechnungen \u00fcberf\u00e4llig +Still\ with\ us\ after\ 90\ days=Nach 90 Tagen noch dabei +Visits\ a\ member\ a\ week=Besuche pro Mitglied und Woche +No-shows=Nicht erschienen +Revenue\ a\ member=Umsatz pro Mitglied +Busy\ hours=Sto\u00dfzeiten +Check-ins\ over\ the\ last\ eight\ weeks.=Check-ins der letzten acht Wochen. +Classes=Kurse +Plan\ mix=Tarifmix +Coaches=Coaches +No\ members\ yet.=Noch keine Mitglieder. +No\ classes\ in\ the\ last\ four\ weeks.=Keine Kurse in den letzten vier Wochen. +{0}%\ full,\ {1}%\ no-show={0}% belegt, {1}% nicht erschienen +{0}\:\ {1}%\ full={0}: {1}% belegt +No\ plans\ sold\ yet.=Noch keine Tarife verkauft. +{0}\:\ {1}\ members={0}: {1} Mitglieder +{0}\ members={0} Mitglieder +{0}\ a\ month={0} im Monat +No\ classes\ taught\ in\ the\ last\ four\ weeks.=In den letzten vier Wochen keine Kurse gegeben. +{0}\ classes,\ {1}%\ full,\ {2}%\ show\ up={0} Kurse, {1}% belegt, {2}% erschienen +Mon=Mo +Tue=Di +Wed=Mi +Thu=Do +Fri=Fr +Sat=Sa +Sun=So +No\ visits\ yet=Noch keine Besuche +Busiest\:\ {0}\ at\ {1}\:00=Am vollsten: {0} um {1}:00 +No\ members\ yet=Noch keine Mitglieder +{0}\ active\ members,\ {1}\ twelve\ weeks\ ago={0} aktive Mitglieder, vor zw\u00f6lf Wochen {1} +Search\ by\ name=Nach Namen suchen +All=Alle +{0}\ in\ 30\ days={0} in 30 Tagen +Nobody\ by\ that\ name=Niemand mit diesem Namen +No\ members\ here=Hier sind keine Mitglieder +Try\ another\ filter.=Versuche einen anderen Filter. +Never\ in=Noch nie da +In\ today=Heute da +Yesterday=Gestern +{0}\ days\ ago=vor {0} Tagen +Joined\ {0}=Beigetreten am {0} +Visits\ in\ 30\ days=Besuche in 30 Tagen +Week\ streak=Wochen in Folge +Lifetime\ value=Gesamtwert +Visits\ a\ week=Besuche pro Woche +11\ weeks\ ago=Vor 11 Wochen +Favourite\ classes=Lieblingskurse +{0}\ visits={0} Besuche +No\ classes\ yet.=Noch keine Kurse. +No\ invoices=Keine Rechnungen +Failed=Fehlgeschlagen +Open=Offen +Void=Stornieren +Collected\ in\ 30\ days=Eingenommen in 30 Tagen +Outstanding=Offen +Attempt\ {0},\ next\ {1}=Versuch {0}, n\u00e4chster {1} +Attempt\ {0},\ no\ more\ retries=Versuch {0}, keine weiteren +Void\ invoice=Rechnung stornieren +{0}\ for\ {1}\ will\ not\ be\ charged.\ A\ membership\ it\ held\ up\ runs\ again.={0} f\u00fcr {1} wird nicht berechnet. Eine dadurch angehaltene Mitgliedschaft l\u00e4uft wieder. +Invoice\ voided=Rechnung storniert +Your\ month=Dein Monat +Places\ filled=Pl\u00e4tze belegt +Showed\ up=Erschienen +Regulars=Stammg\u00e4ste +Kudos=Kudos +Check-ins\ a\ week=Check-ins pro Woche +7\ weeks\ ago=Vor 7 Wochen +Top\ classes=Beliebteste Kurse +{0}\ more\ than\ twelve\ weeks\ ago={0} mehr als vor zwölf Wochen +{0}\ fewer\ than\ twelve\ weeks\ ago={0} weniger als vor zwölf Wochen +Paid\ per\ pack=Pro Paket bezahlt +Full=Voll +Your\ game=Dein Spiel +Leaderboard=Bestenliste +You=Du +Rank\ {0}\ of\ {1}=Platz {0} von {1} +10\ points\ a\ check-in,\ 5\ a\ kudos\ and\ 25\ a\ full\ class.=10 Punkte pro Check-in, 5 pro Kudos und 25 pro voller Klasse. +{0}\ check-ins={0} Check-ins +Full\ house=Volles Haus +Packed\ month=Voller Monat +Ten\ regulars=Zehn Stammg\u00e4ste +Crowd\ favourite=Publikumsliebling +No\ empty\ mats=Keine leeren Matten +Eight-week\ run=Acht Wochen am St\u00fcck +Hundred\ club=Hunderterclub +Top\ coach=Top-Coach +Fill\ every\ place\ in\ a\ class.=Belege jeden Platz in einer Klasse. +Fill\ five\ classes\ in\ four\ weeks.=F\u00fclle f\u00fcnf Klassen in vier Wochen. +Have\ ten\ members\ come\ to\ three\ or\ more\ of\ your\ classes\ in\ four\ weeks.=Zehn Mitglieder kommen in vier Wochen zu drei oder mehr deiner Klassen. +Collect\ 25\ kudos\ on\ workouts\ from\ your\ classes\ in\ four\ weeks.=Sammle in vier Wochen 25 Kudos f\u00fcr Workouts aus deinen Klassen. +Have\ nine\ in\ ten\ booked\ members\ show\ up,\ over\ twenty\ or\ more\ check-ins.=Neun von zehn gebuchten Mitgliedern erscheinen, bei zwanzig oder mehr Check-ins. +Teach\ every\ week\ for\ eight\ weeks\ running.=Unterrichte acht Wochen lang jede Woche. +Coach\ a\ hundred\ check-ins\ in\ eight\ weeks.=Betreue hundert Check-ins in acht Wochen. +Coach\ more\ people\ than\ anyone\ in\ the\ studio\ over\ four\ weeks.=Betreue in vier Wochen mehr Menschen als alle anderen im Studio. +Accounts=Konten +Search\ by\ name\ or\ e-mail=Nach Name oder E-Mail suchen +Flagged=Markiert +Blocked=Gesperrt +No\ accounts\ here=Keine Konten hier +not\ verified=nicht best\u00e4tigt +Roles=Rollen +Visits=Besuche +Spent=Ausgegeben +Teaches\ classes,\ sees\ rosters\ and\ the\ live\ view.=Unterrichtet, sieht Teilnehmerlisten und die Live-Ansicht. +Checks\ people\ in\ at\ the\ door\ and\ takes\ walk-ins.=Checkt Leute am Eingang ein und nimmt Laufkundschaft an. +Runs\ the\ studio\:\ everything\ on\ these\ screens.=F\u00fchrt das Studio: alles auf diesen Seiten. +No\ waiver=Kein Haftungsverzicht +Nothing\ done\ to\ this\ account\ yet.=Mit diesem Konto ist noch nichts geschehen. +Clear\ the\ flag=Markierung entfernen +Flag\ for\ a\ closer\ look=Zur Pr\u00fcfung markieren +Flag\ account=Konto markieren +Other\ owners\ see\ the\ flag\ and\ why.\ The\ account\ keeps\ working.=Andere Inhaber sehen die Markierung und den Grund. Das Konto funktioniert weiter. +Flag=Markieren +Lift\ the\ block=Sperre aufheben +Block\ account=Konto sperren +They\ are\ signed\ out\ at\ once\ and\ cannot\ sign\ in,\ book\ or\ check\ in\ until\ the\ block\ is\ lifted.=Die Person wird sofort abgemeldet und kann sich nicht anmelden, buchen oder einchecken, bis die Sperre aufgehoben ist. +Block=Sperren +Block\ lifted=Sperre aufgehoben +Flag\ cleared=Markierung entfernt +Roles\ changed=Rollen ge\u00e4ndert +Approved=Genehmigt +Rejected=Abgelehnt +Withdraw=Zur\u00fcckziehen +Account\ holder=Kontoinhaber +Child=Kind diff --git a/scripts/pulse/common/src/main/l10n/Bundle_en.properties b/scripts/pulse/common/src/main/l10n/Bundle_en.properties new file mode 100644 index 00000000000..898c28fc536 --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle_en.properties @@ -0,0 +1,672 @@ +# Pulse in English. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=false +language.name=English + +Dashboard=Dashboard +Today=Today +Verified=Verified +This\ week=This week +Accept=Accept +Decline=Decline +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Could not reach the server. Check your connection. +Please\ sign\ in\ again.=Please sign in again. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Your account is not allowed to do that. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Too many attempts. Wait a moment and try again. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Too many attempts. Wait a minute and try again. +Something\ went\ wrong\ ({0}).\ Try\ again.=Something went wrong ({0}). Try again. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=That e-mail and password do not match an account. +Signing\ in\ failed.\ Try\ again.=Signing in failed. Try again. +Home=Home +Payment=Payment +Cash=Cash +Card=Card +Message=Message +Not\ now=Not now +Switch\ to=Switch to +Settings=Settings +Sign\ out=Sign out +No\ connection=No connection +The\ server\ at\ {0}\ did\ not\ answer.=The server at {0} did not answer. +Try\ again=Try again +Server\ address=Server address +Create\ an\ account=Create an account +Name=Name +E-mail=E-mail +Mobile\ number=Mobile number +Password=Password +At\ least\ 8\ characters=At least 8 characters +Create\ account=Create account +Server=Server +Address=Address +Save=Save +Account=Account +Verify=Verify +Pay\ with=Pay with +Notifications=Notifications +Receipts=Receipts +Offers=Offers +Appearance=Appearance +Language=Language +Theme=Theme +System=System +Light=Light +Dark=Dark +Distances=Distances +Kilometres=Kilometres +Miles=Miles +About=About +Terms\ of\ service=Terms of service +Privacy\ policy=Privacy policy +Sign\ in=Sign in +Enter\ your\ e-mail\ and\ password.=Enter your e-mail and password. +{0}\ ft={0} ft +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Verify\ your\ number=Verify your number +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=We sent a six-digit code to your phone. +We\ sent\ a\ six-digit\ code\ to\ {0}.=We sent a six-digit code to {0}. +Code=Code +Send\ a\ new\ code=Send a new code + +# Onboarding applications, payments, the admin console and the account. +A\ sentence\ the\ record\ will\ keep=A sentence the record will keep +About\ you=About you +Add\ a\ card=Add a card +Back=Back +Cancelled=Cancelled +Card\ number=Card number +Change\ password=Change password +Change\ the\ password\ you\ sign\ in\ with=Change the password you sign in with +Colour=Colour +Current\ password=Current password +Date\ of\ birth=Date of birth +Default=Default +Delete\ account=Delete account +Delete\ my\ account=Delete my account +Delete\ your\ account?=Delete your account? +Everyone=Everyone +Expires=Expires +Expires\ {0}=Expires {0} +History=History +I\ have\ finished=I have finished +Keep\ my\ account=Keep my account +Name\ on\ the\ card=Name on the card +New\ password=New password +New\ password,\ again=New password, again +Open\ the\ secure\ page=Open the secure page +Paid=Paid +Profile=Profile +Reason=Reason +Remove\ this\ card=Remove this card +Save\ card=Save card +Say\ why,\ in\ a\ few\ words.=Say why, in a few words. +Security\ code=Security code +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Tap a card to make it the one that pays unless you say otherwise. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=That is not a card number. Check the digits. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=The security code is the three or four digits on the card. +The\ two\ new\ passwords\ are\ not\ the\ same.=The two new passwords are not the same. +This\ month=This month +Wallet=Wallet +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Write the expiry date as month and year: 04/28. +Your\ card\ goes\ straight\ to\ the\ bank=Your card goes straight to the bank +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Your cards, and which of them pays by default +Your\ password\ was\ changed=Your password was changed +Usage\ statistics=Usage statistics +Help\ improve\ Pulse=Help improve Pulse +No\ personal\ details=No personal details +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Never your name, e-mail, phone number, places or card details +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Turn it on or off at any time in Settings +Share\ usage\ statistics=Share usage statistics + +# Ordering, kitchens and deliveries +Active=Active +Add=Add +Description=Description +Free=Free +Hours=Hours +Menu=Menu +New=New +Price=Price +Privacy=Privacy +Register=Register +Remove=Remove +The\ address\ of\ the\ Pulse\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=The address of the Pulse server this app talks to. Changing it signs you out. +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=This is a development server, so no text was sent. Your code is {0}. +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Pulse\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=The payment provider's own page opens in your browser. Pulse never sees the number. Come back here when you have finished. +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=This server takes no real payments. Use the test card 4242 4242 4242 4242 with any date ahead and any three digits. +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Share which screens and features are used. Never your name, places or payment details. +Pulse\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Pulse can count which screens are opened and which features are used, so that we can see what works and fix what does not. +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=With usage statistics turned on, the app also counts which screens are opened and which features are used, and reports that to Codename One's analytics service under a made-up id for this device. Your name, e-mail, phone number, places and card details are never part of it, and turning it off in Settings stops it. +The\ server\ changed.\ Sign\ in\ again.=The server changed. Sign in again. + +# Pulse: member and front desk +A\ frozen\ plan\ is\ not\ billed\ and\ its\ classes\ cannot\ be\ booked.\ It\ starts\ again\ by\ itself\ on\ the\ last\ day.=A frozen plan is not billed and its classes cannot be booked. It starts again by itself on the last day. +A\ sample\ gym\ and\ studio\ app\ built\ with\ Codename\ One=A sample gym and studio app built with Codename One +Add\ a\ child=Add a child +Add\ to\ calendar=Add to calendar +Added\ to\ your\ calendar=Added to your calendar +After\ every\ payment=After every payment +Age\ {0}=Age {0} +Ages\ {0}\ to\ {1}=Ages {0} to {1} +Allergies,\ asthma,\ an\ injury\ -\ optional=Allergies, asthma, an injury - optional +Amount=Amount +An\ hour\ before\ every\ class\ you\ booked=An hour before every class you booked +Anything\ the\ coach\ should\ know=Anything the coach should know +Away=Away +Away\ periods=Away periods +Billing\ paused=Billing paused +Book=Book +Booked=Booked +Bookings=Bookings +Calendar\ access\ was\ not\ allowed=Calendar access was not allowed +Campaigns=Campaigns +Cancelled\ by\ the\ studio=Cancelled by the studio +Challenges,\ offers\ and\ news=Challenges, offers and news +Check\ in=Check in +Class=Class +Class\ is\ full.\ You\ are\ number\ {0}\ in\ the\ queue=Class is full. You are number {0} in the queue +Class\ reminders=Class reminders +Class\ templates=Class templates +Coaching=Coaching +Coming\ up=Coming up +Could\ not\ add\ it\ to\ your\ calendar=Could not add it to your calendar +Day\ pass=Day pass +Desk=Desk +Find\ a\ member=Find a member +Freeze=Freeze +Freeze\ the\ plan\ for\ these\ dates=Freeze the plan for these dates +From=From +Front\ desk=Front desk +Frozen=Frozen +Full,\ {0}\ waiting=Full, {0} waiting +Going\ somewhere?\ Tell\ us\ and\ we\ will\ not\ nag\ you.=Going somewhere? Tell us and we will not nag you. +Guest=Guest +Hold\ the\ fob\ near\ the\ top=Hold the fob near the top +Household=Household +I'll\ be\ away=I'll be away +Inbox=Inbox +Injured=Injured +Injury=Injury +Invoices=Invoices +Join\ for\ {0}=Join for {0} +Kids'\ classes\ are\ by\ age,\ so\ the\ timetable\ shows\ them\ the\ classes\ they\ can\ join.\ You\ sign\ their\ waiver\ next.=Kids' classes are by age, so the timetable shows them the classes they can join. You sign their waiver next. +Legal=Legal +Let\ them\ in=Let them in +Live\ class=Live class +Lookup=Lookup +Lost\ your\ phone\ or\ fob?\ Replace\ the\ pass=Lost your phone or fob? Replace the pass +Manage\ membership=Manage membership +Member=Member +Membership=Membership +Membership\ pass=Membership pass +My\ bookings=My bookings +My\ classes=My classes +Name\ or\ e-mail=Name or e-mail +Needed\ for\ reminders\ and\ the\ front\ desk=Needed for reminders and the front desk +News\ from\ the\ studio\ shows\ up\ here.=News from the studio shows up here. +No\ classes=No classes +No\ classes\ attended\ yet.=No classes attended yet. +No\ invoices\ yet.=No invoices yet. +No\ plan\ yet.\ Pick\ one\ to\ start\ booking\ classes.=No plan yet. Pick one to start booking classes. +No\ time\ away=No time away +Nobody\ in\ the\ household\ is\ on\ a\ plan\ yet.=Nobody in the household is on a plan yet. +Not\ admitted=Not admitted +Nothing\ booked.=Nothing booked. +Nothing\ on\ this\ day\ for\ {0}.=Nothing on this day for {0}. +Nothing\ on\ this\ day.=Nothing on this day. +Nothing\ yet=Nothing yet +Nudges=Nudges +Or\ type\ the\ pass=Or type the pass +Other=Other +Owner=Owner +Owner\ mode=Owner mode +Paid\ by=Paid by +Paid.\ Thank\ you=Paid. Thank you +Pass=Pass +Pause\ billing=Pause billing +Pay\ now=Pay now +Payment\ due=Payment due +Plan=Plan +Plans=Plans +Plans\ and\ pricing=Plans and pricing +Pulse=Pulse +Pulse\ is\ a\ sample.\ No\ real\ classes\ are\ booked,\ no\ studio\ is\ opened\ to\ you\ and\ no\ money\ changes\ hands.=Pulse is a sample. No real classes are booked, no studio is opened to you and no money changes hands. +Queue\ \#{0}=Queue #{0} +Roster=Roster +Scan\ pass=Scan pass +Schedule=Schedule +See\ plans=See plans +Shifts=Shifts +Sign\ waiver=Sign waiver +Some\ classes\ are\ for\ an\ age\ band,\ so\ the\ timetable\ needs\ to\ know.\ Children\ are\ added\ later,\ from\ Household.=Some classes are for an age band, so the timetable needs to know. Children are added later, from Household. +Staff=Staff +Starting=Starting +Studio=Studio +Studio\ owner=Studio owner +Tap\ fob=Tap fob +The\ fob\ could\ not\ be\ read=The fob could not be read +The\ scan\ failed.\ Try\ again=The scan failed. Try again +Their\ first\ and\ last\ name=Their first and last name +This\ device\ cannot\ read\ NFC=This device cannot read NFC +This\ device\ has\ no\ calendar\ to\ add\ to=This device has no calendar to add to +This\ device\ has\ no\ scanner.\ Type\ the\ pass\ instead=This device has no scanner. Type the pass instead +Time\ away=Time away +Timetable=Timetable +Trainer=Trainer +Trainer\ mode=Trainer mode +Travel=Travel +Travelling=Travelling +Travelling\ or\ injured?\ No\ nudges,\ and\ billing\ can\ pause=Travelling or injured? No nudges, and billing can pause +Trial=Trial +Unfreeze=Unfreeze +Unreachable=Unreachable +Until=Until +Visit=Visit +Visits\ this\ month=Visits this month +Waiting\ for\ the\ next\ member.=Waiting for the next member. +Welcome\ in=Welcome in +Welcome\ to\ Pulse=Welcome to Pulse +What\ your\ coaches\ will\ call\ you=What your coaches will call you +Why=Why +With\ {0}=With {0} +Your\ bookings,\ visits,\ household\ and\ settings\ are\ kept\ with\ your\ account\ on\ the\ server\ you\ signed\ in\ to.\ Your\ membership\ pass\ is\ a\ code\ this\ phone\ works\ out\ from\ a\ secret\ that\ server\ gave\ it,\ and\ nothing\ else\ about\ you\ is\ in\ it.=Your bookings, visits, household and settings are kept with your account on the server you signed in to. Your membership pass is a code this phone works out from a secret that server gave it, and nothing else about you is in it. +Your\ name=Your name +Your\ old\ pass\ and\ fob\ no\ longer\ open\ the\ door=Your old pass and fob no longer open the door +Your\ plan\ ends\ at\ the\ end\ of\ this\ period=Your plan ends at the end of this period +Your\ plan,\ freezes\ and\ invoices=Your plan, freezes and invoices +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Classes\ you\ attended\ stay\ in\ the\ studio's\ records,\ under\ the\ email\ address\ you\ signed\ in\ with.=Your sign-in, your profile, your saved cards and your settings are removed for good. Classes you attended stay in the studio's records, under the email address you signed in with. +ends\ {0}=ends {0} +for\ the\ follow-up=for the follow-up +frozen\ until\ {0}=frozen until {0} +renews\ {0}=renews {0} +{0}\ classes\ left={0} classes left +{0}\ is\ in={0} is in +{0}\ of\ {1}\ places\ left={0} of {1} places left +{0}\ starts\ in\ an\ hour.\ See\ you\ there,\ {1}={0} starts in an hour. See you there, {1} +{0}\ weeks\ ago={0} weeks ago + +First\ class=First class +Ten\ classes=Ten classes +Fifty\ classes=Fifty classes +Four-week\ streak=Four-week streak +Twelve-week\ streak=Twelve-week streak +Heart\ tracked=Heart tracked +Strapped\ in=Strapped in +Zone\ 5=Zone 5 +Ten\ hours=Ten hours +Come\ to\ your\ first\ class.=Come to your first class. +Come\ to\ ten\ classes.=Come to ten classes. +Come\ to\ fifty\ classes.=Come to fifty classes. +Train\ every\ week\ for\ four\ weeks\ running.=Train every week for four weeks running. +Train\ every\ week\ for\ twelve\ weeks\ running.=Train every week for twelve weeks running. +Record\ a\ workout\ with\ your\ heart\ rate.=Record a workout with your heart rate. +Wear\ a\ heart-rate\ strap\ or\ a\ watch\ in\ a\ class.=Wear a heart-rate strap or a watch in a class. +Spend\ a\ minute\ flat\ out,\ in\ zone\ 5.=Spend a minute flat out, in zone 5. +Record\ ten\ hours\ of\ workouts.=Record ten hours of workouts. +{0},\ not\ earned\ yet={0}, not earned yet +Earned=Earned +Not\ earned\ yet=Not earned yet +YYYY-MM-DD=YYYY-MM-DD +zone\ {0}\:\ {1}\ min=zone {0}: {1} min +Your\ studio\ in\ your\ pocket.=Your studio in your pocket. +Book\ classes\ for\ you\ and\ your\ family\ in\ two\ taps=Book classes for you and your family in two taps +Walk\ in\ with\ a\ pass\ on\ your\ phone\ or\ your\ watch=Walk in with a pass on your phone or your watch +Earn\ points\ and\ badges\ for\ every\ class\ you\ show\ up\ to=Earn points and badges for every class you show up to +Walk-in=Walk-in +Just\ in=Just in +Checked\ in=Checked in +In=In +Turned\ away=Turned away +Monthly=Monthly +Annual=Annual +Class\ pack=Class pack +Drop-in=Drop-in +New\ plan=New plan +Retired=Retired +Unlimited\ monthly=Unlimited monthly +What\ the\ plan\ includes=What the plan includes +Kind=Kind +Months\ per\ period=Months per period +Classes\ included\ (0\ for\ unlimited)=Classes included (0 for unlimited) +Off-peak\ only=Off-peak only +On\ sale=On sale +A\ plan\ needs\ a\ name\ and\ a\ price.=A plan needs a name and a price. +Plan\ saved=Plan saved +{0}\ sent,\ {1}\ opened,\ {2}\ booked={0} sent, {1} opened, {2} booked +No\ campaigns\ sent\ yet.=No campaigns sent yet. +New\ campaign=New campaign +Class\ regulars=Class regulars +Lapsed\ members=Lapsed members +Every\ member=Every member +Audience=Audience +Lapsed=Lapsed +Name,\ for\ you=Name, for you +Spin\ Tuesday\ push=Spin Tuesday push +Title=Title +A\ place\ is\ waiting=A place is waiting +Tuesday's\ spin\ has\ room.\ Come\ ride\ with\ us.=Tuesday's spin has room. Come ride with us. +Places\ left\ in\ {0}=Places left in {0} +{0}\ on\ {1}\ has\ room\ for\ you.\ Tap\ to\ book.={0} on {1} has room for you. Tap to book. +Win\ back=Win back +We\ miss\ you=We miss you +Your\ next\ class\ is\ on\ us.\ Tap\ to\ see\ the\ timetable.=Your next class is on us. Tap to see the timetable. +Send=Send +Sent\ to\ {0}\ members=Sent to {0} members +Schedule\ a\ class=Schedule a class +Nothing\ on\ the\ timetable\ this\ week.=Nothing on the timetable this week. +When=When +Where=Where +Substitute=Substitute +{0}\ takes\ the\ class={0} takes the class +Cancel\ class=Cancel class +Everyone\ booked\ is\ told,\ with\ your\ reason,\ and\ their\ credit\ is\ returned.=Everyone booked is told, with your reason, and their credit is returned. +Class\ cancelled=Class cancelled +Add\ a\ class\ template,\ a\ room\ and\ a\ trainer\ first.=Add a class template, a room and a trainer first. +Room=Room +Places\ (blank\ for\ the\ template's)=Places (blank for the template's) +Weeks=Weeks +{0}\ classes\ scheduled={0} classes scheduled +Members=Members +Places\ taken=Places taken +At\ risk=At risk +{0}\ joined\ and\ {1}\ left\ in\ 30\ days;\ {2}\ check-ins\ this\ week,\ {3}\ invoices\ past\ due.={0} joined and {1} left in 30 days; {2} check-ins this week, {3} invoices past due. +{0}\ in\ the\ last\ 30\ days={0} in the last 30 days +Popular\ classes=Popular classes +No\ bookings\ in\ the\ last\ month.=No bookings in the last month. +Under-filled\ this\ week=Under-filled this week +Every\ class\ is\ filling\ up.=Every class is filling up. +Win\ back\ {0}\ lapsing\ members=Win back {0} lapsing members +Win\ back\ a\ lapsing\ member=Win back a lapsing member +Revenue=Revenue +{0}\ classes\ this\ week={0} classes this week +Roles\ saved=Roles saved +Offered=Offered +No\ shifts\ on\ the\ rota.=No shifts on the rota. +Add\ a\ shift=Add a shift +Nobody\ on\ the\ staff\ yet.=Nobody on the staff yet. +Who=Who +Duty=Duty +Starts=Starts +Shift\ added=Shift added +New\ template=New template +{0}\ places={0} places +intensity\ {0}\ of\ 5=intensity {0} of 5 +Spin\ 45=Spin 45 +What\ the\ class\ is\ like=What the class is like +Category=Category +Cardio=Cardio +Minutes=Minutes +minutes=minutes +Places=Places +Intensity,\ 1\ to\ 5=Intensity, 1 to 5 +Youngest\ age\ (0\ for\ any)=Youngest age (0 for any) +Oldest\ age\ (0\ for\ any)=Oldest age (0 for any) +Bookable=Bookable +A\ template\ needs\ a\ name.=A template needs a name. +Template\ saved=Template saved +Workouts=Workouts +Share\ with\ friends=Share with friends +RECORDING=RECORDING +ON\ NOW=ON NOW +Wear\ a\ strap\ or\ your\ watch\ and\ this\ fills\ in.\ Worn\ sensors\ earn\ a\ bonus.=Wear a strap or your watch and this fills in. Worn sensors earn a bonus. +Record\ it\ to\ log\ the\ workout\ and\ earn\ points\ for\ the\ class.=Record it to log the workout and earn points for the class. +Record\ this\ class=Record this class +Workout\ saved\:\ +{0}\ points=Workout saved: +{0} points +Workout\ saved=Workout saved +End\ workout=End workout +Health\ data\ could\ not\ be\ read=Health data could not be read +{0}\ workouts\ added={0} workouts added +Recent=Recent +No\ workouts\ yet.=No workouts yet. +Measured\ by\ a\ strap=Measured by a strap +Measured\ by\ a\ watch=Measured by a watch +{0}\ points={0} points +Workout=Workout +PULSE\ MEMBER=PULSE MEMBER +Due=Due +Cancel\ at\ the\ end\ of\ this\ period=Cancel at the end of this period +Friends=Friends +NEXT\ CLASS=NEXT CLASS +Nothing\ booked\ yet=Nothing booked yet +Pick\ a\ class\ and\ your\ pass\ and\ reminder\ are\ ready.=Pick a class and your pass and reminder are ready. +Find\ a\ class=Find a class +for\ {0}=for {0} +Show\ my\ pass=Show my pass +Details=Details +Top\ level=Top level +{0}\ week\ streak={0} week streak +visits=visits +kcal=kcal +Badges\ \ {0}/{1}=Badges {0}/{1} +Wear\ a\ heart\ rate\ strap\ or\ your\ watch\ to\ class\:\ +25\ points\ a\ class\ and\ the\ Strapped\ in\ badge.=Wear a heart rate strap or your watch to class: +25 points a class and the Strapped in badge. +Morning,\ {0}=Morning, {0} +Afternoon,\ {0}=Afternoon, {0} +Evening,\ {0}=Evening, {0} +On\ now=On now +In\ {0}\ min=In {0} min +In\ {0}\ h=In {0} h +In\ {0}\ days=In {0} days +{0}\ left={0} left +{0}\ waiting={0} waiting +Cancel\ this\ booking=Cancel this booking +No\ friends\ yet.\ Training\ is\ better\ together.=No friends yet. Training is better together. +Feed=Feed +Your\ friends'\ classes\ and\ workouts\ appear\ here.=Your friends' classes and workouts appear here. +Add\ a\ friend=Add a friend +Their\ email=Their email +Send\ request=Send request +Enter\ their\ email\ address.=Enter their email address. +Request\ sent=Request sent +Wants\ to\ be\ friends=Wants to be friends +Asked,\ waiting\ for\ them=Asked, waiting for them +Getting\ started=Getting started +Invite=Invite +Remove\ friend=Remove friend +You\ did\ {1}=You did {1} +{0}\ did\ {1}={0} did {1} +{0}\ kudos={0} kudos +Invite\ {0}=Invite {0} +Which\ class?=Which class? +You\ are\ going=You are going +{0}\ is\ invited={0} is invited +Pass\ for\ {0}=Pass for {0} +Waiver\ needed\ before\ class=Waiver needed before class +Queue=Queue +MM/YY=MM/YY +Choose\ how\ to\ pay.=Choose how to pay. +Pay\ at\ the\ front\ desk=Pay at the front desk +Live\ view=Live view +Medical\:\ {0}=Medical: {0} +First\ visit=First visit +{0}\ waiting\ for\ a\ place={0} waiting for a place +Nobody\ booked\ yet.=Nobody booked yet. +Marked\ present=Marked present +Marked\ absent=Marked absent +Heart\ rate\ zones,\ live.\ Zone\ 5\ is\ flat\ out.=Heart rate zones, live. Zone 5 is flat out. +Nobody\ in\ yet=Nobody in yet +Members\ appear\ here\ once\ they\ are\ booked.=Members appear here once they are booked. +Zone\ {0}=Zone {0} +No\ reading=No reading +No\ classes\ this\ week=No classes this week +Classes\ the\ studio\ gives\ you\ appear\ here.=Classes the studio gives you appear here. +UP\ NEXT=UP NEXT +{0}\ of\ {1}\ booked={0} of {1} booked +Open\ live\ view=Open live view +Tomorrow=Tomorrow +No\ class\ today=No class today +Mine=Mine +Withdraw\ offer=Withdraw offer +Offer\ swap=Offer swap +Offered\ to\ the\ team=Offered to the team +Offer\ withdrawn=Offer withdrawn +No\ shifts\ to\ come.=No shifts to come. +Offered\ by\ others=Offered by others +Take=Take +The\ shift\ is\ yours=The shift is yours +Nothing\ offered\ right\ now.=Nothing offered right now. +1\ friend=1 friend +{0}\ friends={0} friends +Wear\ a\ watch\ or\ a\ strap\ to\ earn\ bonus\ points.=Wear a watch or a strap to earn bonus points. +Check-ins=Check-ins +Walk-ins=Walk-ins +Import\ from\ Health=Import from Health +Joined=Joined +Left=Left +Key\ fob=Key fob +Pass\ number=Pass number +Past\ due=Past due +Monthly\ recurring=Monthly recurring +Show\ this\ at\ the\ front\ desk.\ It\ refreshes\ every\ {0}\ seconds.=Show this at the front desk. It refreshes every {0} seconds. +Takings=Takings +Visits\ /\ wk=Visits / wk +Visits\ each\ week=Visits each week +{0}\ booked={0} booked +{0}\ bpm\ avg={0} bpm avg +{0}\ more\ to\ level\ {1}={0} more to level {1} +Jan=Jan +Feb=Feb +Mar=Mar +Apr=Apr +May=May +Jun=Jun +Jul=Jul +Aug=Aug +Sep=Sep +Oct=Oct +Nov=Nov +Dec=Dec + +# The owner's insights, members and billing, and the trainer's month. +Insights=Insights +Billing=Billing +Busy\ hours,\ growth,\ classes\ and\ coaches=Busy hours, growth, classes and coaches +{0}\ at\ risk={0} at risk +{0}\ invoices\ past\ due={0} invoices past due +Still\ with\ us\ after\ 90\ days=Still with us after 90 days +Visits\ a\ member\ a\ week=Visits a member a week +No-shows=No-shows +Revenue\ a\ member=Revenue a member +Busy\ hours=Busy hours +Check-ins\ over\ the\ last\ eight\ weeks.=Check-ins over the last eight weeks. +Classes=Classes +Plan\ mix=Plan mix +Coaches=Coaches +No\ members\ yet.=No members yet. +No\ classes\ in\ the\ last\ four\ weeks.=No classes in the last four weeks. +{0}%\ full,\ {1}%\ no-show={0}% full, {1}% no-show +{0}\:\ {1}%\ full={0}: {1}% full +No\ plans\ sold\ yet.=No plans sold yet. +{0}\:\ {1}\ members={0}: {1} members +{0}\ members={0} members +{0}\ a\ month={0} a month +No\ classes\ taught\ in\ the\ last\ four\ weeks.=No classes taught in the last four weeks. +{0}\ classes,\ {1}%\ full,\ {2}%\ show\ up={0} classes, {1}% full, {2}% show up +Mon=Mon +Tue=Tue +Wed=Wed +Thu=Thu +Fri=Fri +Sat=Sat +Sun=Sun +No\ visits\ yet=No visits yet +Busiest\:\ {0}\ at\ {1}\:00=Busiest: {0} at {1}:00 +No\ members\ yet=No members yet +{0}\ active\ members,\ {1}\ twelve\ weeks\ ago={0} active members, {1} twelve weeks ago +Search\ by\ name=Search by name +All=All +{0}\ in\ 30\ days={0} in 30 days +Nobody\ by\ that\ name=Nobody by that name +No\ members\ here=No members here +Try\ another\ filter.=Try another filter. +Never\ in=Never in +In\ today=In today +Yesterday=Yesterday +{0}\ days\ ago={0} days ago +Joined\ {0}=Joined {0} +Visits\ in\ 30\ days=Visits in 30 days +Week\ streak=Week streak +Lifetime\ value=Lifetime value +Visits\ a\ week=Visits a week +11\ weeks\ ago=11 weeks ago +Favourite\ classes=Favourite classes +{0}\ visits={0} visits +No\ classes\ yet.=No classes yet. +No\ invoices=No invoices +Failed=Failed +Open=Open +Void=Void +Collected\ in\ 30\ days=Collected in 30 days +Outstanding=Outstanding +Attempt\ {0},\ next\ {1}=Attempt {0}, next {1} +Attempt\ {0},\ no\ more\ retries=Attempt {0}, no more retries +Void\ invoice=Void invoice +{0}\ for\ {1}\ will\ not\ be\ charged.\ A\ membership\ it\ held\ up\ runs\ again.={0} for {1} will not be charged. A membership it held up runs again. +Invoice\ voided=Invoice voided +Your\ month=Your month +Places\ filled=Places filled +Showed\ up=Showed up +Regulars=Regulars +Kudos=Kudos +Check-ins\ a\ week=Check-ins a week +7\ weeks\ ago=7 weeks ago +Top\ classes=Top classes +{0}\ more\ than\ twelve\ weeks\ ago={0} more than twelve weeks ago +{0}\ fewer\ than\ twelve\ weeks\ ago={0} fewer than twelve weeks ago +Paid\ per\ pack=Paid per pack +Full=Full +Your\ game=Your game +Leaderboard=Leaderboard +You=You +Rank\ {0}\ of\ {1}=Rank {0} of {1} +10\ points\ a\ check-in,\ 5\ a\ kudos\ and\ 25\ a\ full\ class.=10 points a check-in, 5 a kudos and 25 a full class. +{0}\ check-ins={0} check-ins +Full\ house=Full house +Packed\ month=Packed month +Ten\ regulars=Ten regulars +Crowd\ favourite=Crowd favourite +No\ empty\ mats=No empty mats +Eight-week\ run=Eight-week run +Hundred\ club=Hundred club +Top\ coach=Top coach +Fill\ every\ place\ in\ a\ class.=Fill every place in a class. +Fill\ five\ classes\ in\ four\ weeks.=Fill five classes in four weeks. +Have\ ten\ members\ come\ to\ three\ or\ more\ of\ your\ classes\ in\ four\ weeks.=Have ten members come to three or more of your classes in four weeks. +Collect\ 25\ kudos\ on\ workouts\ from\ your\ classes\ in\ four\ weeks.=Collect 25 kudos on workouts from your classes in four weeks. +Have\ nine\ in\ ten\ booked\ members\ show\ up,\ over\ twenty\ or\ more\ check-ins.=Have nine in ten booked members show up, over twenty or more check-ins. +Teach\ every\ week\ for\ eight\ weeks\ running.=Teach every week for eight weeks running. +Coach\ a\ hundred\ check-ins\ in\ eight\ weeks.=Coach a hundred check-ins in eight weeks. +Coach\ more\ people\ than\ anyone\ in\ the\ studio\ over\ four\ weeks.=Coach more people than anyone in the studio over four weeks. +Accounts=Accounts +Search\ by\ name\ or\ e-mail=Search by name or e-mail +Flagged=Flagged +Blocked=Blocked +No\ accounts\ here=No accounts here +not\ verified=not verified +Roles=Roles +Visits=Visits +Spent=Spent +Teaches\ classes,\ sees\ rosters\ and\ the\ live\ view.=Teaches classes, sees rosters and the live view. +Checks\ people\ in\ at\ the\ door\ and\ takes\ walk-ins.=Checks people in at the door and takes walk-ins. +Runs\ the\ studio\:\ everything\ on\ these\ screens.=Runs the studio: everything on these screens. +No\ waiver=No waiver +Nothing\ done\ to\ this\ account\ yet.=Nothing done to this account yet. +Clear\ the\ flag=Clear the flag +Flag\ for\ a\ closer\ look=Flag for a closer look +Flag\ account=Flag account +Other\ owners\ see\ the\ flag\ and\ why.\ The\ account\ keeps\ working.=Other owners see the flag and why. The account keeps working. +Flag=Flag +Lift\ the\ block=Lift the block +Block\ account=Block account +They\ are\ signed\ out\ at\ once\ and\ cannot\ sign\ in,\ book\ or\ check\ in\ until\ the\ block\ is\ lifted.=They are signed out at once and cannot sign in, book or check in until the block is lifted. +Block=Block +Block\ lifted=Block lifted +Flag\ cleared=Flag cleared +Roles\ changed=Roles changed +Approved=Approved +Rejected=Rejected +Withdraw=Withdraw +Account\ holder=Account holder +Child=Child diff --git a/scripts/pulse/common/src/main/l10n/Bundle_es.properties b/scripts/pulse/common/src/main/l10n/Bundle_es.properties new file mode 100644 index 00000000000..1ca005ff462 --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle_es.properties @@ -0,0 +1,672 @@ +# Pulse in Spanish. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=false +language.name=Espa\u00f1ol + +Dashboard=Panel +Today=Hoy +Verified=Verificado +This\ week=Esta semana +Accept=Aceptar +Decline=Rechazar +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=No se pudo contactar con el servidor. Comprueba tu conexi\u00f3n. +Please\ sign\ in\ again.=Vuelve a iniciar sesi\u00f3n. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Tu cuenta no tiene permiso para hacer eso. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Demasiados intentos. Espera un momento y vuelve a intentarlo. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Demasiados intentos. Espera un minuto y vuelve a intentarlo. +Something\ went\ wrong\ ({0}).\ Try\ again.=Algo sali\u00f3 mal ({0}). Int\u00e9ntalo de nuevo. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Ese correo y contrase\u00f1a no corresponden a ninguna cuenta. +Signing\ in\ failed.\ Try\ again.=No se pudo iniciar sesi\u00f3n. Int\u00e9ntalo de nuevo. +Home=Casa +Payment=Pago +Cash=Efectivo +Card=Tarjeta +Message=Mensaje +Not\ now=Ahora no +Switch\ to=Cambiar a +Settings=Ajustes +Sign\ out=Cerrar sesi\u00f3n +No\ connection=Sin conexi\u00f3n +The\ server\ at\ {0}\ did\ not\ answer.=El servidor en {0} no respondi\u00f3. +Try\ again=Reintentar +Server\ address=Direcci\u00f3n del servidor +Create\ an\ account=Crear una cuenta +Name=Nombre +E-mail=Correo electr\u00f3nico +Mobile\ number=N\u00famero de m\u00f3vil +Password=Contrase\u00f1a +At\ least\ 8\ characters=Al menos 8 caracteres +Create\ account=Crear cuenta +Server=Servidor +Address=Direcci\u00f3n +Save=Guardar +Account=Cuenta +Verify=Verificar +Pay\ with=Pagar con +Notifications=Notificaciones +Receipts=Recibos +Offers=Ofertas +Appearance=Apariencia +Language=Idioma +Theme=Tema +System=Sistema +Light=Claro +Dark=Oscuro +Distances=Distancias +Kilometres=Kil\u00f3metros +Miles=Millas +About=Acerca de +Terms\ of\ service=Condiciones del servicio +Privacy\ policy=Pol\u00edtica de privacidad +Sign\ in=Iniciar sesi\u00f3n +Enter\ your\ e-mail\ and\ password.=Introduce tu correo y contrase\u00f1a. +{0}\ ft={0} pies +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Verify\ your\ number=Verifica tu n\u00famero +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Enviamos un c\u00f3digo de seis d\u00edgitos a tu tel\u00e9fono. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Enviamos un c\u00f3digo de seis d\u00edgitos a {0}. +Code=C\u00f3digo +Send\ a\ new\ code=Enviar un c\u00f3digo nuevo + +# Onboarding applications, payments, the admin console and the account. +A\ sentence\ the\ record\ will\ keep=Una frase que quedar\u00e1 registrada +About\ you=Sobre ti +Add\ a\ card=A\u00f1adir una tarjeta +Back=Atr\u00e1s +Cancelled=Cancelados +Card\ number=N\u00famero de tarjeta +Change\ password=Cambiar contrase\u00f1a +Change\ the\ password\ you\ sign\ in\ with=Cambia la contrase\u00f1a con la que entras +Colour=Color +Current\ password=Contrase\u00f1a actual +Date\ of\ birth=Fecha de nacimiento +Default=Predeterminada +Delete\ account=Eliminar cuenta +Delete\ my\ account=Eliminar mi cuenta +Delete\ your\ account?=\u00bfEliminar tu cuenta? +Everyone=Todos +Expires=Caduca +Expires\ {0}=Caduca {0} +History=Historial +I\ have\ finished=He terminado +Keep\ my\ account=Conservar mi cuenta +Name\ on\ the\ card=Nombre en la tarjeta +New\ password=Nueva contrase\u00f1a +New\ password,\ again=Nueva contrase\u00f1a, otra vez +Open\ the\ secure\ page=Abrir la p\u00e1gina segura +Paid=Pagado +Profile=Perfil +Reason=Motivo +Remove\ this\ card=Quitar esta tarjeta +Save\ card=Guardar tarjeta +Say\ why,\ in\ a\ few\ words.=Di por qu\u00e9, en pocas palabras. +Security\ code=C\u00f3digo de seguridad +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Toca una tarjeta para que sea la que paga salvo que digas otra cosa. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Eso no es un n\u00famero de tarjeta. Revisa los d\u00edgitos. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=El c\u00f3digo de seguridad son los tres o cuatro d\u00edgitos de la tarjeta. +The\ two\ new\ passwords\ are\ not\ the\ same.=Las dos contrase\u00f1as nuevas no coinciden. +This\ month=Este mes +Wallet=Cartera +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Escribe la caducidad como mes y a\u00f1o: 04/28. +Your\ card\ goes\ straight\ to\ the\ bank=Tu tarjeta va directa al banco +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Tus tarjetas, y cu\u00e1l paga por defecto +Your\ password\ was\ changed=Tu contrase\u00f1a se cambi\u00f3 +Usage\ statistics=Estad\u00edsticas de uso +Help\ improve\ Pulse=Ayuda a mejorar Pulse +No\ personal\ details=Sin datos personales +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Nunca tu nombre, correo electr\u00f3nico, n\u00famero de tel\u00e9fono, lugares ni datos de tarjeta +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Act\u00edvalo o desact\u00edvalo cuando quieras en Ajustes +Share\ usage\ statistics=Compartir estad\u00edsticas de uso + +# Ordering, kitchens and deliveries +Active=Activo +Add=A\u00f1adir +Description=Descripci\u00f3n +Free=Gratis +Hours=Horario +Menu=Men\u00fa +New=Nuevo +Price=Precio +Privacy=Privacidad +Register=Registrarse +Remove=Quitar +The\ address\ of\ the\ Pulse\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=La direcci\u00f3n del servidor Pulse que usa esta app. Cambiarla cierra tu sesi\u00f3n. +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Este es un servidor de desarrollo, as\u00ed que no se envi\u00f3 ning\u00fan SMS. Tu c\u00f3digo es {0}. +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Pulse\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=La p\u00e1gina del proveedor de pagos se abre en tu navegador. Pulse nunca ve el n\u00famero. Vuelve aqu\u00ed cuando termines. +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Este servidor no acepta pagos reales. Usa la tarjeta de prueba 4242 4242 4242 4242 con cualquier fecha futura y tres d\u00edgitos cualesquiera. +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Comparte qu\u00e9 pantallas y funciones se usan. Nunca tu nombre, tus lugares ni tus datos de pago. +Pulse\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Pulse puede contar qu\u00e9 pantallas se abren y qu\u00e9 funciones se usan, para que podamos ver qu\u00e9 funciona y arreglar lo que no. +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Con las estad\u00edsticas de uso activadas, la app tambi\u00e9n cuenta qu\u00e9 pantallas se abren y qu\u00e9 funciones se usan, y lo env\u00eda al servicio de anal\u00edtica de Codename One con un identificador inventado para este dispositivo. Tu nombre, correo electr\u00f3nico, n\u00famero de tel\u00e9fono, lugares y datos de tarjeta nunca forman parte de ello, y al desactivarlas en Ajustes deja de hacerlo. +The\ server\ changed.\ Sign\ in\ again.=El servidor cambi\u00f3. Vuelve a iniciar sesi\u00f3n. + +First\ class=Primera clase +Ten\ classes=Diez clases +Fifty\ classes=Cincuenta clases +Four-week\ streak=Racha de cuatro semanas +Twelve-week\ streak=Racha de doce semanas +Heart\ tracked=Coraz\u00f3n registrado +Strapped\ in=Con banda +Zone\ 5=Zona 5 +Ten\ hours=Diez horas +Come\ to\ your\ first\ class.=Ven a tu primera clase. +Come\ to\ ten\ classes.=Ven a diez clases. +Come\ to\ fifty\ classes.=Ven a cincuenta clases. +Train\ every\ week\ for\ four\ weeks\ running.=Entrena cada semana durante cuatro semanas seguidas. +Train\ every\ week\ for\ twelve\ weeks\ running.=Entrena cada semana durante doce semanas seguidas. +Record\ a\ workout\ with\ your\ heart\ rate.=Registra un entrenamiento con tu frecuencia card\u00edaca. +Wear\ a\ heart-rate\ strap\ or\ a\ watch\ in\ a\ class.=Usa una banda de pulso o un reloj en una clase. +Spend\ a\ minute\ flat\ out,\ in\ zone\ 5.=Pasa un minuto a tope, en la zona 5. +Record\ ten\ hours\ of\ workouts.=Registra diez horas de entrenamientos. +{0},\ not\ earned\ yet={0}, a\u00fan sin conseguir +Earned=Conseguido +Not\ earned\ yet=A\u00fan sin conseguir +YYYY-MM-DD=AAAA-MM-DD +zone\ {0}\:\ {1}\ min=zona {0}: {1} min +Your\ studio\ in\ your\ pocket.=Tu estudio en el bolsillo. +Book\ classes\ for\ you\ and\ your\ family\ in\ two\ taps=Reserva clases para ti y tu familia con dos toques +Walk\ in\ with\ a\ pass\ on\ your\ phone\ or\ your\ watch=Entra con el pase en tu m\u00f3vil o tu reloj +Earn\ points\ and\ badges\ for\ every\ class\ you\ show\ up\ to=Gana puntos e insignias por cada clase a la que vengas +Walk-in=Sin reserva +Just\ in=Reci\u00e9n llegados +Checked\ in=Registrado +In=Dentro +Turned\ away=Rechazado +Monthly=Mensual +Annual=Anual +Class\ pack=Bono de clases +Drop-in=Entrada suelta +New\ plan=Plan nuevo +Retired=Retirado +Unlimited\ monthly=Mensual ilimitado +What\ the\ plan\ includes=Qu\u00e9 incluye el plan +Kind=Tipo +Months\ per\ period=Meses por periodo +Classes\ included\ (0\ for\ unlimited)=Clases incluidas (0 para ilimitadas) +Off-peak\ only=Solo en horas valle +On\ sale=A la venta +A\ plan\ needs\ a\ name\ and\ a\ price.=Un plan necesita un nombre y un precio. +Plan\ saved=Plan guardado +{0}\ sent,\ {1}\ opened,\ {2}\ booked={0} enviados, {1} abiertos, {2} reservas +No\ campaigns\ sent\ yet.=A\u00fan no se ha enviado ninguna campa\u00f1a. +New\ campaign=Campa\u00f1a nueva +Class\ regulars=Habituales de la clase +Lapsed\ members=Socios inactivos +Every\ member=Todos los socios +Audience=Destinatarios +Lapsed=Inactivos +Name,\ for\ you=Nombre, para ti +Spin\ Tuesday\ push=Aviso del spinning del martes +Title=T\u00edtulo +A\ place\ is\ waiting=Hay un sitio esper\u00e1ndote +Tuesday's\ spin\ has\ room.\ Come\ ride\ with\ us.=Hay sitio en el spinning del martes. Ven a pedalear con nosotros. +Places\ left\ in\ {0}=Plazas libres en {0} +{0}\ on\ {1}\ has\ room\ for\ you.\ Tap\ to\ book.={0} el {1} tiene sitio para ti. Toca para reservar. +Win\ back=Recuperar +We\ miss\ you=Te echamos de menos +Your\ next\ class\ is\ on\ us.\ Tap\ to\ see\ the\ timetable.=Tu pr\u00f3xima clase corre de nuestra cuenta. Toca para ver el horario. +Send=Enviar +Sent\ to\ {0}\ members=Enviado a {0} socios +Schedule\ a\ class=Programar una clase +Nothing\ on\ the\ timetable\ this\ week.=Nada en el horario esta semana. +When=Cu\u00e1ndo +Where=D\u00f3nde +Substitute=Sustituto +{0}\ takes\ the\ class={0} da la clase +Cancel\ class=Cancelar clase +Everyone\ booked\ is\ told,\ with\ your\ reason,\ and\ their\ credit\ is\ returned.=Se avisa a todos los reservados, con tu motivo, y se les devuelve el cr\u00e9dito. +Class\ cancelled=Clase cancelada +Add\ a\ class\ template,\ a\ room\ and\ a\ trainer\ first.=A\u00f1ade primero una plantilla de clase, una sala y un entrenador. +Room=Sala +Places\ (blank\ for\ the\ template's)=Plazas (en blanco, las de la plantilla) +Weeks=Semanas +{0}\ classes\ scheduled={0} clases programadas +Members=Socios +Places\ taken=Plazas ocupadas +At\ risk=En riesgo +{0}\ joined\ and\ {1}\ left\ in\ 30\ days;\ {2}\ check-ins\ this\ week,\ {3}\ invoices\ past\ due.={0} altas y {1} bajas en 30 d\u00edas; {2} entradas esta semana, {3} facturas vencidas. +{0}\ in\ the\ last\ 30\ days={0} en los \u00faltimos 30 d\u00edas +Popular\ classes=Clases populares +No\ bookings\ in\ the\ last\ month.=Sin reservas en el \u00faltimo mes. +Under-filled\ this\ week=Poco llenas esta semana +Every\ class\ is\ filling\ up.=Todas las clases se est\u00e1n llenando. +Win\ back\ {0}\ lapsing\ members=Recuperar a {0} socios que se van +Win\ back\ a\ lapsing\ member=Recuperar a un socio que se va +Revenue=Ingresos +{0}\ classes\ this\ week={0} clases esta semana +Roles\ saved=Roles guardados +Offered=Ofrecido +No\ shifts\ on\ the\ rota.=No hay turnos en el cuadrante. +Add\ a\ shift=A\u00f1adir un turno +Nobody\ on\ the\ staff\ yet.=A\u00fan no hay nadie en el personal. +Who=Qui\u00e9n +Duty=Tarea +Starts=Empieza +Shift\ added=Turno a\u00f1adido +New\ template=Plantilla nueva +{0}\ places={0} plazas +intensity\ {0}\ of\ 5=intensidad {0} de 5 +Spin\ 45=Spinning 45 +What\ the\ class\ is\ like=C\u00f3mo es la clase +Category=Categor\u00eda +Cardio=Cardio +Minutes=Minutos +minutes=minutos +Places=Plazas +Intensity,\ 1\ to\ 5=Intensidad, de 1 a 5 +Youngest\ age\ (0\ for\ any)=Edad m\u00ednima (0 para cualquiera) +Oldest\ age\ (0\ for\ any)=Edad m\u00e1xima (0 para cualquiera) +Bookable=Reservable +A\ template\ needs\ a\ name.=Una plantilla necesita un nombre. +Template\ saved=Plantilla guardada +Workouts=Entrenamientos +Share\ with\ friends=Compartir con amigos +RECORDING=GRABANDO +ON\ NOW=EN CURSO +Wear\ a\ strap\ or\ your\ watch\ and\ this\ fills\ in.\ Worn\ sensors\ earn\ a\ bonus.=Usa una banda o tu reloj y esto se rellena. Los sensores puestos dan un extra. +Record\ it\ to\ log\ the\ workout\ and\ earn\ points\ for\ the\ class.=Gr\u00e1bala para registrar el entrenamiento y ganar puntos por la clase. +Record\ this\ class=Grabar esta clase +Workout\ saved\:\ +{0}\ points=Entrenamiento guardado: +{0} puntos +Workout\ saved=Entrenamiento guardado +End\ workout=Terminar entrenamiento +Health\ data\ could\ not\ be\ read=No se pudieron leer los datos de salud +{0}\ workouts\ added={0} entrenamientos a\u00f1adidos +Recent=Recientes +No\ workouts\ yet.=A\u00fan no hay entrenamientos. +Measured\ by\ a\ strap=Medido con banda +Measured\ by\ a\ watch=Medido con reloj +{0}\ points={0} puntos +Workout=Entrenamiento +PULSE\ MEMBER=SOCIO DE PULSE +Due=Vence +Cancel\ at\ the\ end\ of\ this\ period=Cancelar al final de este periodo +Friends=Amigos +NEXT\ CLASS=PR\u00d3XIMA CLASE +Nothing\ booked\ yet=Nada reservado todav\u00eda +Pick\ a\ class\ and\ your\ pass\ and\ reminder\ are\ ready.=Elige una clase y tu pase y el recordatorio estar\u00e1n listos. +Find\ a\ class=Buscar una clase +for\ {0}=para {0} +Show\ my\ pass=Mostrar mi pase +Details=Detalles +Top\ level=Nivel m\u00e1ximo +{0}\ week\ streak=Racha de {0} semanas +visits=visitas +kcal=kcal +Badges\ \ {0}/{1}=Insignias {0}/{1} +Wear\ a\ heart\ rate\ strap\ or\ your\ watch\ to\ class\:\ +25\ points\ a\ class\ and\ the\ Strapped\ in\ badge.=Lleva una banda de pulso o tu reloj a clase: +25 puntos por clase y la insignia \u00abCon banda\u00bb. +Morning,\ {0}=Buenos d\u00edas, {0} +Afternoon,\ {0}=Buenas tardes, {0} +Evening,\ {0}=Buenas noches, {0} +On\ now=En curso +In\ {0}\ min=En {0} min +In\ {0}\ h=En {0} h +In\ {0}\ days=En {0} d\u00edas +{0}\ left=quedan {0} +{0}\ waiting={0} en espera +Cancel\ this\ booking=Cancelar esta reserva +No\ friends\ yet.\ Training\ is\ better\ together.=A\u00fan no tienes amigos. Entrenar es mejor en compa\u00f1\u00eda. +Feed=Actividad +Your\ friends'\ classes\ and\ workouts\ appear\ here.=Aqu\u00ed aparecen las clases y entrenamientos de tus amigos. +Add\ a\ friend=A\u00f1adir un amigo +Their\ email=Su correo electr\u00f3nico +Send\ request=Enviar solicitud +Enter\ their\ email\ address.=Introduce su correo electr\u00f3nico. +Request\ sent=Solicitud enviada +Wants\ to\ be\ friends=Quiere ser tu amigo +Asked,\ waiting\ for\ them=Solicitud enviada, esperando respuesta +Getting\ started=Empezando +Invite=Invitar +Remove\ friend=Quitar amigo +You\ did\ {1}=Hiciste {1} +{0}\ did\ {1}={0} hizo {1} +{0}\ kudos={0} aplausos +Invite\ {0}=Invitar a {0} +Which\ class?=\u00bfQu\u00e9 clase? +You\ are\ going=Vas a ir +{0}\ is\ invited={0} ha sido invitado +Pass\ for\ {0}=Pase de {0} +Waiver\ needed\ before\ class=Se necesita la renuncia antes de la clase +Queue=Lista de espera +MM/YY=MM/AA +Choose\ how\ to\ pay.=Elige c\u00f3mo pagar. +Pay\ at\ the\ front\ desk=Pagar en recepci\u00f3n +Live\ view=Vista en directo +Medical\:\ {0}=M\u00e9dico: {0} +First\ visit=Primera visita +{0}\ waiting\ for\ a\ place={0} esperando plaza +Nobody\ booked\ yet.=Nadie ha reservado todav\u00eda. +Marked\ present=Marcado como presente +Marked\ absent=Marcado como ausente +Heart\ rate\ zones,\ live.\ Zone\ 5\ is\ flat\ out.=Zonas de frecuencia card\u00edaca, en directo. La zona 5 es a tope. +Nobody\ in\ yet=A\u00fan no ha llegado nadie +Members\ appear\ here\ once\ they\ are\ booked.=Los socios aparecen aqu\u00ed cuando reservan. +Zone\ {0}=Zona {0} +No\ reading=Sin lectura +No\ classes\ this\ week=Sin clases esta semana +Classes\ the\ studio\ gives\ you\ appear\ here.=Aqu\u00ed aparecen las clases que te asigna el estudio. +UP\ NEXT=A CONTINUACI\u00d3N +{0}\ of\ {1}\ booked={0} de {1} reservadas +Open\ live\ view=Abrir vista en directo +Tomorrow=Ma\u00f1ana +No\ class\ today=Hoy no hay clase +Mine=Mis turnos +Withdraw\ offer=Retirar oferta +Offer\ swap=Ofrecer cambio +Offered\ to\ the\ team=Ofrecido al equipo +Offer\ withdrawn=Oferta retirada +No\ shifts\ to\ come.=No hay turnos pr\u00f3ximos. +Offered\ by\ others=Ofrecidos por otros +Take=Tomar +The\ shift\ is\ yours=El turno es tuyo +Nothing\ offered\ right\ now.=Nada ofrecido ahora mismo. +A\ frozen\ plan\ is\ not\ billed\ and\ its\ classes\ cannot\ be\ booked.\ It\ starts\ again\ by\ itself\ on\ the\ last\ day.=Un plan congelado no se cobra y no se pueden reservar sus clases. Se reactiva solo el \u00faltimo d\u00eda. +A\ sample\ gym\ and\ studio\ app\ built\ with\ Codename\ One=Una app de ejemplo para gimnasios y estudios hecha con Codename One +Add\ a\ child=A\u00f1adir un hijo +Add\ to\ calendar=A\u00f1adir al calendario +Added\ to\ your\ calendar=A\u00f1adido a tu calendario +After\ every\ payment=Despu\u00e9s de cada pago +Age\ {0}=Edad {0} +Ages\ {0}\ to\ {1}=De {0} a {1} a\u00f1os +Allergies,\ asthma,\ an\ injury\ -\ optional=Alergias, asma, una lesi\u00f3n - opcional +Amount=Importe +An\ hour\ before\ every\ class\ you\ booked=Una hora antes de cada clase que reservaste +Anything\ the\ coach\ should\ know=Algo que el entrenador deba saber +Away=Ausente +Away\ periods=Periodos de ausencia +Billing\ paused=Facturaci\u00f3n en pausa +Book=Reservar +Booked=Reservado +Bookings=Reservas +Calendar\ access\ was\ not\ allowed=No se permiti\u00f3 el acceso al calendario +Campaigns=Campa\u00f1as +Cancelled\ by\ the\ studio=Cancelada por el estudio +Challenges,\ offers\ and\ news=Retos, ofertas y novedades +Check\ in=Registrar entrada +Class=Clase +Class\ is\ full.\ You\ are\ number\ {0}\ in\ the\ queue=La clase est\u00e1 llena. Eres el n\u00famero {0} en la lista de espera +Class\ reminders=Recordatorios de clases +Class\ templates=Plantillas de clase +Coaching=Entrenamiento personal +Coming\ up=Pr\u00f3ximamente +Could\ not\ add\ it\ to\ your\ calendar=No se pudo a\u00f1adir a tu calendario +Day\ pass=Pase de un d\u00eda +Desk=Recepci\u00f3n +Find\ a\ member=Buscar un socio +Freeze=Congelar +Freeze\ the\ plan\ for\ these\ dates=Congelar el plan en estas fechas +From=Desde +Front\ desk=Recepci\u00f3n +Frozen=Congelado +Full,\ {0}\ waiting=Completa, {0} en espera +Going\ somewhere?\ Tell\ us\ and\ we\ will\ not\ nag\ you.=\u00bfTe vas de viaje? D\u00ednoslo y no te molestaremos. +Guest=Invitado +Hold\ the\ fob\ near\ the\ top=Acerca el llavero a la parte superior +Household=Hogar +I'll\ be\ away=Estar\u00e9 ausente +Inbox=Bandeja de entrada +Injured=Lesionado +Injury=Lesi\u00f3n +Invoices=Facturas +Join\ for\ {0}=Unirse por {0} +Kids'\ classes\ are\ by\ age,\ so\ the\ timetable\ shows\ them\ the\ classes\ they\ can\ join.\ You\ sign\ their\ waiver\ next.=Las clases infantiles son por edad, as\u00ed que el horario muestra las que pueden hacer. A continuaci\u00f3n firmas su renuncia. +Legal=Legal +Let\ them\ in=Dejar pasar +Live\ class=Clase en directo +Lookup=Buscar +Lost\ your\ phone\ or\ fob?\ Replace\ the\ pass=\u00bfPerdiste el m\u00f3vil o el llavero? Reemplaza el pase +Manage\ membership=Gestionar la membres\u00eda +Member=Socio +Membership=Membres\u00eda +Membership\ pass=Pase de socio +My\ bookings=Mis reservas +My\ classes=Mis clases +Name\ or\ e-mail=Nombre o correo electr\u00f3nico +Needed\ for\ reminders\ and\ the\ front\ desk=Necesario para los recordatorios y la recepci\u00f3n +News\ from\ the\ studio\ shows\ up\ here.=Aqu\u00ed aparecen las novedades del estudio. +No\ classes=Sin clases +No\ classes\ attended\ yet.=A\u00fan no has asistido a ninguna clase. +No\ invoices\ yet.=A\u00fan no hay facturas. +No\ plan\ yet.\ Pick\ one\ to\ start\ booking\ classes.=A\u00fan no tienes plan. Elige uno para empezar a reservar clases. +No\ time\ away=Sin ausencias +Nobody\ in\ the\ household\ is\ on\ a\ plan\ yet.=Nadie del hogar tiene un plan todav\u00eda. +Not\ admitted=No admitido +Nothing\ booked.=Nada reservado. +Nothing\ on\ this\ day\ for\ {0}.=Nada este d\u00eda para {0}. +Nothing\ on\ this\ day.=Nada este d\u00eda. +Nothing\ yet=Nada todav\u00eda +Nudges=Avisos +Or\ type\ the\ pass=O escribe el pase +Other=Otro +Owner=Propietario +Owner\ mode=Modo propietario +Paid\ by=Pagado con +Paid.\ Thank\ you=Pagado. Gracias +Pass=Pase +Pause\ billing=Pausar la facturaci\u00f3n +Pay\ now=Pagar ahora +Payment\ due=Pago pendiente +Plan=Plan +Plans=Planes +Plans\ and\ pricing=Planes y precios +Pulse=Pulse +Pulse\ is\ a\ sample.\ No\ real\ classes\ are\ booked,\ no\ studio\ is\ opened\ to\ you\ and\ no\ money\ changes\ hands.=Pulse es un ejemplo. No se reservan clases reales, no se te abre ning\u00fan estudio y no se mueve dinero. +Queue\ \#{0}=Lista de espera n.\u00ba {0} +Roster=Lista de asistentes +Scan\ pass=Escanear pase +Schedule=Horario +See\ plans=Ver planes +Shifts=Turnos +Sign\ waiver=Firmar la renuncia +Some\ classes\ are\ for\ an\ age\ band,\ so\ the\ timetable\ needs\ to\ know.\ Children\ are\ added\ later,\ from\ Household.=Algunas clases son para un grupo de edad, as\u00ed que el horario necesita saberlo. Los ni\u00f1os se a\u00f1aden despu\u00e9s, desde Hogar. +Staff=Personal +Starting=Empieza +Studio=Estudio +Studio\ owner=Due\u00f1o del estudio +Tap\ fob=Acercar llavero +The\ fob\ could\ not\ be\ read=No se pudo leer el llavero +The\ scan\ failed.\ Try\ again=El escaneo fall\u00f3. Int\u00e9ntalo de nuevo +Their\ first\ and\ last\ name=Su nombre y apellido +This\ device\ cannot\ read\ NFC=Este dispositivo no puede leer NFC +This\ device\ has\ no\ calendar\ to\ add\ to=Este dispositivo no tiene calendario al que a\u00f1adir +This\ device\ has\ no\ scanner.\ Type\ the\ pass\ instead=Este dispositivo no tiene esc\u00e1ner. Escribe el pase +Time\ away=Tiempo de ausencia +Timetable=Horario +Trainer=Entrenador +Trainer\ mode=Modo entrenador +Travel=Viaje +Travelling=De viaje +Travelling\ or\ injured?\ No\ nudges,\ and\ billing\ can\ pause=\u00bfDe viaje o lesionado? Sin avisos y la facturaci\u00f3n puede pausarse +Trial=Prueba +Unfreeze=Descongelar +Unreachable=Inaccesible +Until=Hasta +Visit=Visita +Visits\ this\ month=Visitas este mes +Waiting\ for\ the\ next\ member.=Esperando al siguiente socio. +Welcome\ in=Bienvenido +Welcome\ to\ Pulse=Te damos la bienvenida a Pulse +What\ your\ coaches\ will\ call\ you=C\u00f3mo te llamar\u00e1n tus entrenadores +Why=Por qu\u00e9 +With\ {0}=Con {0} +Your\ bookings,\ visits,\ household\ and\ settings\ are\ kept\ with\ your\ account\ on\ the\ server\ you\ signed\ in\ to.\ Your\ membership\ pass\ is\ a\ code\ this\ phone\ works\ out\ from\ a\ secret\ that\ server\ gave\ it,\ and\ nothing\ else\ about\ you\ is\ in\ it.=Tus reservas, visitas, hogar y ajustes se guardan con tu cuenta en el servidor en el que iniciaste sesi\u00f3n. Tu pase de socio es un c\u00f3digo que este m\u00f3vil calcula a partir de un secreto que le dio ese servidor, y no contiene nada m\u00e1s sobre ti. +Your\ name=Tu nombre +Your\ old\ pass\ and\ fob\ no\ longer\ open\ the\ door=Tu pase y llavero anteriores ya no abren la puerta +Your\ plan\ ends\ at\ the\ end\ of\ this\ period=Tu plan termina al final de este periodo +Your\ plan,\ freezes\ and\ invoices=Tu plan, congelaciones y facturas +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Classes\ you\ attended\ stay\ in\ the\ studio's\ records,\ under\ the\ email\ address\ you\ signed\ in\ with.=Tu acceso, tu perfil, tus tarjetas guardadas y tus ajustes se eliminan para siempre. Las clases a las que asististe quedan en los registros del estudio, con el correo con el que iniciaste sesi\u00f3n. +ends\ {0}=termina {0} +for\ the\ follow-up=para el seguimiento +frozen\ until\ {0}=congelado hasta {0} +renews\ {0}=se renueva {0} +{0}\ classes\ left=Quedan {0} clases +{0}\ is\ in={0} ha entrado +{0}\ of\ {1}\ places\ left=Quedan {0} de {1} plazas +{0}\ starts\ in\ an\ hour.\ See\ you\ there,\ {1}={0} empieza en una hora. Nos vemos all\u00ed, {1} +{0}\ weeks\ ago=Hace {0} semanas +1\ friend=1 amigo +{0}\ friends={0} amigos +Wear\ a\ watch\ or\ a\ strap\ to\ earn\ bonus\ points.=Un entrenamiento con reloj o banda de pecho suma puntos extra. +Check-ins=Entradas +Walk-ins=Sin cita +Import\ from\ Health=Importar de Salud +Joined=Altas +Left=Bajas +Key\ fob=Llavero +Pass\ number=N\u00famero de pase +Past\ due=Vencidas +Monthly\ recurring=Recurrente mensual +Show\ this\ at\ the\ front\ desk.\ It\ refreshes\ every\ {0}\ seconds.=Mu\u00e9stralo en recepci\u00f3n. Se renueva cada {0} segundos. +Takings=Ingresos +Visits\ /\ wk=Visitas / sem. +Visits\ each\ week=Visitas por semana +{0}\ booked={0} reservas +{0}\ bpm\ avg={0} lpm de media +{0}\ more\ to\ level\ {1}={0} m\u00e1s para el nivel {1} +Jan=ene +Feb=feb +Mar=mar +Apr=abr +May=may +Jun=jun +Jul=jul +Aug=ago +Sep=sep +Oct=oct +Nov=nov +Dec=dic + +# The owner's insights, members and billing, and the trainer's month. +Insights=Estad\u00edsticas +Billing=Facturaci\u00f3n +Busy\ hours,\ growth,\ classes\ and\ coaches=Horas punta, crecimiento, clases y entrenadores +{0}\ at\ risk={0} en riesgo +{0}\ invoices\ past\ due={0} facturas vencidas +Still\ with\ us\ after\ 90\ days=Siguen tras 90 d\u00edas +Visits\ a\ member\ a\ week=Visitas por miembro a la semana +No-shows=No presentados +Revenue\ a\ member=Ingresos por miembro +Busy\ hours=Horas punta +Check-ins\ over\ the\ last\ eight\ weeks.=Entradas de las \u00faltimas ocho semanas. +Classes=Clases +Plan\ mix=Mezcla de planes +Coaches=Entrenadores +No\ members\ yet.=A\u00fan no hay miembros. +No\ classes\ in\ the\ last\ four\ weeks.=No hubo clases en las \u00faltimas cuatro semanas. +{0}%\ full,\ {1}%\ no-show={0}% lleno, {1}% no presentados +{0}\:\ {1}%\ full={0}: {1}% lleno +No\ plans\ sold\ yet.=A\u00fan no se ha vendido ning\u00fan plan. +{0}\:\ {1}\ members={0}: {1} miembros +{0}\ members={0} miembros +{0}\ a\ month={0} al mes +No\ classes\ taught\ in\ the\ last\ four\ weeks.=Nadie dio clases en las \u00faltimas cuatro semanas. +{0}\ classes,\ {1}%\ full,\ {2}%\ show\ up={0} clases, {1}% lleno, {2}% asistencia +Mon=Lun +Tue=Mar +Wed=Mi\u00e9 +Thu=Jue +Fri=Vie +Sat=S\u00e1b +Sun=Dom +No\ visits\ yet=A\u00fan no hay visitas +Busiest\:\ {0}\ at\ {1}\:00=M\u00e1s concurrido: {0} a las {1}:00 +No\ members\ yet=A\u00fan no hay miembros +{0}\ active\ members,\ {1}\ twelve\ weeks\ ago={0} miembros activos, {1} hace doce semanas +Search\ by\ name=Buscar por nombre +All=Todos +{0}\ in\ 30\ days={0} en 30 d\u00edas +Nobody\ by\ that\ name=Nadie con ese nombre +No\ members\ here=No hay miembros aqu\u00ed +Try\ another\ filter.=Prueba otro filtro. +Never\ in=Nunca ha venido +In\ today=Vino hoy +Yesterday=Ayer +{0}\ days\ ago=hace {0} d\u00edas +Joined\ {0}=Alta el {0} +Visits\ in\ 30\ days=Visitas en 30 d\u00edas +Week\ streak=Semanas seguidas +Lifetime\ value=Valor total +Visits\ a\ week=Visitas por semana +11\ weeks\ ago=Hace 11 semanas +Favourite\ classes=Clases favoritas +{0}\ visits={0} visitas +No\ classes\ yet.=A\u00fan no hay clases. +No\ invoices=No hay facturas +Failed=Fallida +Open=Abierta +Void=Anular +Collected\ in\ 30\ days=Cobrado en 30 d\u00edas +Outstanding=Pendiente +Attempt\ {0},\ next\ {1}=Intento {0}, siguiente {1} +Attempt\ {0},\ no\ more\ retries=Intento {0}, sin m\u00e1s reintentos +Void\ invoice=Anular factura +{0}\ for\ {1}\ will\ not\ be\ charged.\ A\ membership\ it\ held\ up\ runs\ again.=No se cobrar\u00e1n {0} a {1}. La membres\u00eda que bloqueaba vuelve a estar activa. +Invoice\ voided=Factura anulada +Your\ month=Tu mes +Places\ filled=Plazas ocupadas +Showed\ up=Asistieron +Regulars=Habituales +Kudos=Kudos +Check-ins\ a\ week=Registros por semana +7\ weeks\ ago=Hace 7 semanas +Top\ classes=Clases principales +{0}\ more\ than\ twelve\ weeks\ ago={0} más que hace doce semanas +{0}\ fewer\ than\ twelve\ weeks\ ago={0} menos que hace doce semanas +Paid\ per\ pack=Se paga por bono +Full=Completo +Your\ game=Tu juego +Leaderboard=Clasificaci\u00f3n +You=T\u00fa +Rank\ {0}\ of\ {1}=Puesto {0} de {1} +10\ points\ a\ check-in,\ 5\ a\ kudos\ and\ 25\ a\ full\ class.=10 puntos por registro, 5 por felicitaci\u00f3n y 25 por clase llena. +{0}\ check-ins={0} registros +Full\ house=Lleno total +Packed\ month=Mes lleno +Ten\ regulars=Diez habituales +Crowd\ favourite=Favorito del p\u00fablico +No\ empty\ mats=Sin esterillas vac\u00edas +Eight-week\ run=Ocho semanas seguidas +Hundred\ club=Club de los cien +Top\ coach=Mejor entrenador +Fill\ every\ place\ in\ a\ class.=Llena todas las plazas de una clase. +Fill\ five\ classes\ in\ four\ weeks.=Llena cinco clases en cuatro semanas. +Have\ ten\ members\ come\ to\ three\ or\ more\ of\ your\ classes\ in\ four\ weeks.=Que diez miembros vengan a tres o m\u00e1s de tus clases en cuatro semanas. +Collect\ 25\ kudos\ on\ workouts\ from\ your\ classes\ in\ four\ weeks.=Consigue 25 felicitaciones en entrenamientos de tus clases en cuatro semanas. +Have\ nine\ in\ ten\ booked\ members\ show\ up,\ over\ twenty\ or\ more\ check-ins.=Que nueve de cada diez reservas acudan, con veinte registros o m\u00e1s. +Teach\ every\ week\ for\ eight\ weeks\ running.=Da clase cada semana durante ocho semanas seguidas. +Coach\ a\ hundred\ check-ins\ in\ eight\ weeks.=Entrena a cien registros en ocho semanas. +Coach\ more\ people\ than\ anyone\ in\ the\ studio\ over\ four\ weeks.=Entrena a m\u00e1s personas que nadie del estudio en cuatro semanas. +Accounts=Cuentas +Search\ by\ name\ or\ e-mail=Buscar por nombre o correo +Flagged=Marcada +Blocked=Bloqueada +No\ accounts\ here=No hay cuentas aqu\u00ed +not\ verified=sin verificar +Roles=Roles +Visits=Visitas +Spent=Gastado +Teaches\ classes,\ sees\ rosters\ and\ the\ live\ view.=Da clases, ve las listas y la vista en directo. +Checks\ people\ in\ at\ the\ door\ and\ takes\ walk-ins.=Registra a la gente en la puerta y atiende visitas. +Runs\ the\ studio\:\ everything\ on\ these\ screens.=Dirige el estudio: todo en estas pantallas. +No\ waiver=Sin exenci\u00f3n +Nothing\ done\ to\ this\ account\ yet.=A\u00fan no se ha hecho nada con esta cuenta. +Clear\ the\ flag=Quitar la marca +Flag\ for\ a\ closer\ look=Marcar para revisar +Flag\ account=Marcar cuenta +Other\ owners\ see\ the\ flag\ and\ why.\ The\ account\ keeps\ working.=Los dem\u00e1s propietarios ven la marca y el motivo. La cuenta sigue funcionando. +Flag=Marcar +Lift\ the\ block=Quitar el bloqueo +Block\ account=Bloquear cuenta +They\ are\ signed\ out\ at\ once\ and\ cannot\ sign\ in,\ book\ or\ check\ in\ until\ the\ block\ is\ lifted.=Se cierra su sesi\u00f3n al instante y no puede entrar, reservar ni registrarse hasta que se quite el bloqueo. +Block=Bloquear +Block\ lifted=Bloqueo quitado +Flag\ cleared=Marca quitada +Roles\ changed=Roles cambiados +Approved=Aprobado +Rejected=Rechazado +Withdraw=Retirar +Account\ holder=Titular de la cuenta +Child=Ni\u00f1o diff --git a/scripts/pulse/common/src/main/l10n/Bundle_fr.properties b/scripts/pulse/common/src/main/l10n/Bundle_fr.properties new file mode 100644 index 00000000000..3dba3c12386 --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle_fr.properties @@ -0,0 +1,672 @@ +# Pulse in French. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=false +language.name=Fran\u00e7ais + +Dashboard=Tableau de bord +Today=Aujourd'hui +Verified=V\u00e9rifi\u00e9 +This\ week=Cette semaine +Accept=Accepter +Decline=Refuser +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Impossible de joindre le serveur. V\u00e9rifiez votre connexion. +Please\ sign\ in\ again.=Veuillez vous reconnecter. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Votre compte n'est pas autoris\u00e9 \u00e0 faire cela. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Trop de tentatives. Patientez un instant et r\u00e9essayez. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Trop de tentatives. Patientez une minute et r\u00e9essayez. +Something\ went\ wrong\ ({0}).\ Try\ again.=Une erreur s'est produite ({0}). R\u00e9essayez. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Cet e-mail et ce mot de passe ne correspondent \u00e0 aucun compte. +Signing\ in\ failed.\ Try\ again.=La connexion a \u00e9chou\u00e9. R\u00e9essayez. +Home=Domicile +Payment=Paiement +Cash=Esp\u00e8ces +Card=Carte +Message=Message +Not\ now=Pas maintenant +Switch\ to=Passer \u00e0 +Settings=R\u00e9glages +Sign\ out=Se d\u00e9connecter +No\ connection=Pas de connexion +The\ server\ at\ {0}\ did\ not\ answer.=Le serveur \u00e0 {0} n'a pas r\u00e9pondu. +Try\ again=R\u00e9essayer +Server\ address=Adresse du serveur +Create\ an\ account=Cr\u00e9er un compte +Name=Nom +E-mail=E-mail +Mobile\ number=Num\u00e9ro de mobile +Password=Mot de passe +At\ least\ 8\ characters=Au moins 8 caract\u00e8res +Create\ account=Cr\u00e9er le compte +Server=Serveur +Address=Adresse +Save=Enregistrer +Account=Compte +Verify=V\u00e9rifier +Pay\ with=Payer avec +Notifications=Notifications +Receipts=Re\u00e7us +Offers=Offres +Appearance=Apparence +Language=Langue +Theme=Th\u00e8me +System=Syst\u00e8me +Light=Clair +Dark=Sombre +Distances=Distances +Kilometres=Kilom\u00e8tres +Miles=Miles +About=\u00c0 propos +Terms\ of\ service=Conditions d'utilisation +Privacy\ policy=Politique de confidentialit\u00e9 +Sign\ in=Se connecter +Enter\ your\ e-mail\ and\ password.=Saisissez votre e-mail et votre mot de passe. +{0}\ ft={0} pi +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Verify\ your\ number=V\u00e9rifiez votre num\u00e9ro +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Nous avons envoy\u00e9 un code \u00e0 six chiffres sur votre t\u00e9l\u00e9phone. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Nous avons envoy\u00e9 un code \u00e0 six chiffres au {0}. +Code=Code +Send\ a\ new\ code=Envoyer un nouveau code + +# Onboarding applications, payments, the admin console and the account. +A\ sentence\ the\ record\ will\ keep=Une phrase qui restera au dossier +About\ you=\u00c0 propos de vous +Add\ a\ card=Ajouter une carte +Back=Retour +Cancelled=Annul\u00e9es +Card\ number=Num\u00e9ro de carte +Change\ password=Changer le mot de passe +Change\ the\ password\ you\ sign\ in\ with=Changez le mot de passe de connexion +Colour=Couleur +Current\ password=Mot de passe actuel +Date\ of\ birth=Date de naissance +Default=Par d\u00e9faut +Delete\ account=Supprimer le compte +Delete\ my\ account=Supprimer mon compte +Delete\ your\ account?=Supprimer votre compte ? +Everyone=Tous +Expires=Expire +Expires\ {0}=Expire {0} +History=Historique +I\ have\ finished=J'ai termin\u00e9 +Keep\ my\ account=Garder mon compte +Name\ on\ the\ card=Nom sur la carte +New\ password=Nouveau mot de passe +New\ password,\ again=Nouveau mot de passe, encore +Open\ the\ secure\ page=Ouvrir la page s\u00e9curis\u00e9e +Paid=Pay\u00e9 +Profile=Profil +Reason=Motif +Remove\ this\ card=Retirer cette carte +Save\ card=Enregistrer la carte +Say\ why,\ in\ a\ few\ words.=Dites pourquoi, en quelques mots. +Security\ code=Code de s\u00e9curit\u00e9 +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Touchez une carte pour qu'elle paie par d\u00e9faut. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Ce n'est pas un num\u00e9ro de carte. V\u00e9rifiez les chiffres. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=Le code de s\u00e9curit\u00e9 correspond aux trois ou quatre chiffres de la carte. +The\ two\ new\ passwords\ are\ not\ the\ same.=Les deux nouveaux mots de passe sont diff\u00e9rents. +This\ month=Ce mois-ci +Wallet=Portefeuille +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Saisissez la date d'expiration sous la forme mois et ann\u00e9e : 04/28. +Your\ card\ goes\ straight\ to\ the\ bank=Votre carte va directement \u00e0 la banque +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Vos cartes, et celle qui paie par d\u00e9faut +Your\ password\ was\ changed=Votre mot de passe a \u00e9t\u00e9 modifi\u00e9 +Usage\ statistics=Statistiques d'utilisation +Help\ improve\ Pulse=Aidez \u00e0 am\u00e9liorer Pulse +No\ personal\ details=Aucune donn\u00e9e personnelle +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Jamais votre nom, votre e-mail, votre num\u00e9ro de t\u00e9l\u00e9phone, vos lieux ni les donn\u00e9es de votre carte +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Activez ou d\u00e9sactivez-les \u00e0 tout moment dans R\u00e9glages +Share\ usage\ statistics=Partager les statistiques d'utilisation + +# Ordering, kitchens and deliveries +Active=Actif +Add=Ajouter +Description=Description +Free=Gratuit +Hours=Horaires +Menu=Menu +New=Nouveau +Price=Prix +Privacy=Confidentialit\u00e9 +Register=S'inscrire +Remove=Retirer +The\ address\ of\ the\ Pulse\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=L'adresse du serveur Pulse utilis\u00e9 par cette application. La modifier vous d\u00e9connecte. +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Ceci est un serveur de d\u00e9veloppement, aucun SMS n'a donc \u00e9t\u00e9 envoy\u00e9. Votre code est {0}. +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Pulse\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=La page du prestataire de paiement s'ouvre dans votre navigateur. Pulse ne voit jamais le num\u00e9ro. Revenez ici une fois termin\u00e9. +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Ce serveur n'accepte aucun paiement r\u00e9el. Utilisez la carte de test 4242 4242 4242 4242 avec une date future et trois chiffres quelconques. +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Partagez quels \u00e9crans et quelles fonctions sont utilis\u00e9s. Jamais votre nom, vos lieux ni vos donn\u00e9es de paiement. +Pulse\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Pulse peut compter quels \u00e9crans sont ouverts et quelles fonctions sont utilis\u00e9es, pour que nous puissions voir ce qui marche et corriger ce qui ne marche pas. +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Lorsque les statistiques d'utilisation sont activ\u00e9es, l'application compte aussi quels \u00e9crans sont ouverts et quelles fonctions sont utilis\u00e9es, et le transmet au service d'analyse de Codename One sous un identifiant invent\u00e9 pour cet appareil. Votre nom, votre e-mail, votre num\u00e9ro de t\u00e9l\u00e9phone, vos lieux et les donn\u00e9es de votre carte n'en font jamais partie, et les d\u00e9sactiver dans R\u00e9glages y met fin. +The\ server\ changed.\ Sign\ in\ again.=Le serveur a chang\u00e9. Reconnectez-vous. + +First\ class=Premier cours +Ten\ classes=Dix cours +Fifty\ classes=Cinquante cours +Four-week\ streak=S\u00e9rie de quatre semaines +Twelve-week\ streak=S\u00e9rie de douze semaines +Heart\ tracked=C\u0153ur suivi +Strapped\ in=Sangle boucl\u00e9e +Zone\ 5=Zone 5 +Ten\ hours=Dix heures +Come\ to\ your\ first\ class.=Viens \u00e0 ton premier cours. +Come\ to\ ten\ classes.=Viens \u00e0 dix cours. +Come\ to\ fifty\ classes.=Viens \u00e0 cinquante cours. +Train\ every\ week\ for\ four\ weeks\ running.=Entra\u00eene-toi chaque semaine pendant quatre semaines d'affil\u00e9e. +Train\ every\ week\ for\ twelve\ weeks\ running.=Entra\u00eene-toi chaque semaine pendant douze semaines d'affil\u00e9e. +Record\ a\ workout\ with\ your\ heart\ rate.=Enregistre une s\u00e9ance avec ta fr\u00e9quence cardiaque. +Wear\ a\ heart-rate\ strap\ or\ a\ watch\ in\ a\ class.=Porte une ceinture cardiaque ou une montre en cours. +Spend\ a\ minute\ flat\ out,\ in\ zone\ 5.=Passe une minute \u00e0 fond, en zone 5. +Record\ ten\ hours\ of\ workouts.=Enregistre dix heures d'entra\u00eenement. +{0},\ not\ earned\ yet={0}, pas encore obtenu +Earned=Obtenu +Not\ earned\ yet=Pas encore obtenu +YYYY-MM-DD=AAAA-MM-JJ +zone\ {0}\:\ {1}\ min=zone {0} : {1} min +Your\ studio\ in\ your\ pocket.=Ton studio dans ta poche. +Book\ classes\ for\ you\ and\ your\ family\ in\ two\ taps=R\u00e9serve des cours pour toi et ta famille en deux touches +Walk\ in\ with\ a\ pass\ on\ your\ phone\ or\ your\ watch=Entre avec ton pass sur ton t\u00e9l\u00e9phone ou ta montre +Earn\ points\ and\ badges\ for\ every\ class\ you\ show\ up\ to=Gagne des points et des badges pour chaque cours auquel tu viens +Walk-in=Sans r\u00e9servation +Just\ in=Tout juste arriv\u00e9s +Checked\ in=Enregistr\u00e9 +In=Entr\u00e9 +Turned\ away=Refus\u00e9 +Monthly=Mensuel +Annual=Annuel +Class\ pack=Carte de cours +Drop-in=S\u00e9ance \u00e0 l'unit\u00e9 +New\ plan=Nouvelle formule +Retired=Retir\u00e9 +Unlimited\ monthly=Mensuel illimit\u00e9 +What\ the\ plan\ includes=Ce que comprend la formule +Kind=Type +Months\ per\ period=Mois par p\u00e9riode +Classes\ included\ (0\ for\ unlimited)=Cours inclus (0 pour illimit\u00e9) +Off-peak\ only=Heures creuses uniquement +On\ sale=En vente +A\ plan\ needs\ a\ name\ and\ a\ price.=Une formule a besoin d'un nom et d'un prix. +Plan\ saved=Formule enregistr\u00e9e +{0}\ sent,\ {1}\ opened,\ {2}\ booked={0} envoy\u00e9s, {1} ouverts, {2} r\u00e9servations +No\ campaigns\ sent\ yet.=Aucune campagne envoy\u00e9e pour l'instant. +New\ campaign=Nouvelle campagne +Class\ regulars=Habitu\u00e9s du cours +Lapsed\ members=Membres inactifs +Every\ member=Tous les membres +Audience=Destinataires +Lapsed=Inactifs +Name,\ for\ you=Nom, pour toi +Spin\ Tuesday\ push=Notification du spinning du mardi +Title=Titre +A\ place\ is\ waiting=Une place t'attend +Tuesday's\ spin\ has\ room.\ Come\ ride\ with\ us.=Il reste de la place au spinning de mardi. Viens p\u00e9daler avec nous. +Places\ left\ in\ {0}=Places libres pour {0} +{0}\ on\ {1}\ has\ room\ for\ you.\ Tap\ to\ book.={0} le {1} a de la place pour toi. Touche pour r\u00e9server. +Win\ back=Reconqu\u00e9rir +We\ miss\ you=Tu nous manques +Your\ next\ class\ is\ on\ us.\ Tap\ to\ see\ the\ timetable.=Ton prochain cours est offert. Touche pour voir le planning. +Send=Envoyer +Sent\ to\ {0}\ members=Envoy\u00e9 \u00e0 {0} membres +Schedule\ a\ class=Programmer un cours +Nothing\ on\ the\ timetable\ this\ week.=Rien au planning cette semaine. +When=Quand +Where=O\u00f9 +Substitute=Rempla\u00e7ant +{0}\ takes\ the\ class={0} prend le cours +Cancel\ class=Annuler le cours +Everyone\ booked\ is\ told,\ with\ your\ reason,\ and\ their\ credit\ is\ returned.=Tous les inscrits sont pr\u00e9venus, avec ton motif, et leur cr\u00e9dit est rendu. +Class\ cancelled=Cours annul\u00e9 +Add\ a\ class\ template,\ a\ room\ and\ a\ trainer\ first.=Ajoute d'abord un mod\u00e8le de cours, une salle et un coach. +Room=Salle +Places\ (blank\ for\ the\ template's)=Places (vide : celles du mod\u00e8le) +Weeks=Semaines +{0}\ classes\ scheduled={0} cours programm\u00e9s +Members=Membres +Places\ taken=Places occup\u00e9es +At\ risk=\u00c0 risque +{0}\ joined\ and\ {1}\ left\ in\ 30\ days;\ {2}\ check-ins\ this\ week,\ {3}\ invoices\ past\ due.={0} arriv\u00e9es et {1} d\u00e9parts en 30 jours ; {2} entr\u00e9es cette semaine, {3} factures en retard. +{0}\ in\ the\ last\ 30\ days={0} sur les 30 derniers jours +Popular\ classes=Cours populaires +No\ bookings\ in\ the\ last\ month.=Aucune r\u00e9servation le mois dernier. +Under-filled\ this\ week=Peu remplis cette semaine +Every\ class\ is\ filling\ up.=Tous les cours se remplissent. +Win\ back\ {0}\ lapsing\ members=Reconqu\u00e9rir {0} membres qui s'\u00e9loignent +Win\ back\ a\ lapsing\ member=Reconqu\u00e9rir un membre qui s'\u00e9loigne +Revenue=Revenus +{0}\ classes\ this\ week={0} cours cette semaine +Roles\ saved=R\u00f4les enregistr\u00e9s +Offered=Propos\u00e9 +No\ shifts\ on\ the\ rota.=Aucun service au planning. +Add\ a\ shift=Ajouter un service +Nobody\ on\ the\ staff\ yet.=Personne dans l'\u00e9quipe pour l'instant. +Who=Qui +Duty=Poste +Starts=D\u00e9but +Shift\ added=Service ajout\u00e9 +New\ template=Nouveau mod\u00e8le +{0}\ places={0} places +intensity\ {0}\ of\ 5=intensit\u00e9 {0} sur 5 +Spin\ 45=Spinning 45 +What\ the\ class\ is\ like=\u00c0 quoi ressemble le cours +Category=Cat\u00e9gorie +Cardio=Cardio +Minutes=Minutes +minutes=minutes +Places=Places +Intensity,\ 1\ to\ 5=Intensit\u00e9, de 1 \u00e0 5 +Youngest\ age\ (0\ for\ any)=\u00c2ge minimum (0 pour tous) +Oldest\ age\ (0\ for\ any)=\u00c2ge maximum (0 pour tous) +Bookable=R\u00e9servable +A\ template\ needs\ a\ name.=Un mod\u00e8le a besoin d'un nom. +Template\ saved=Mod\u00e8le enregistr\u00e9 +Workouts=S\u00e9ances +Share\ with\ friends=Partager avec les amis +RECORDING=ENREGISTREMENT +ON\ NOW=EN COURS +Wear\ a\ strap\ or\ your\ watch\ and\ this\ fills\ in.\ Worn\ sensors\ earn\ a\ bonus.=Porte une ceinture ou ta montre et ceci se remplit. Les capteurs port\u00e9s rapportent un bonus. +Record\ it\ to\ log\ the\ workout\ and\ earn\ points\ for\ the\ class.=Enregistre-la pour noter la s\u00e9ance et gagner des points pour le cours. +Record\ this\ class=Enregistrer ce cours +Workout\ saved\:\ +{0}\ points=S\u00e9ance enregistr\u00e9e : +{0} points +Workout\ saved=S\u00e9ance enregistr\u00e9e +End\ workout=Terminer la s\u00e9ance +Health\ data\ could\ not\ be\ read=Les donn\u00e9es de sant\u00e9 n'ont pas pu \u00eatre lues +{0}\ workouts\ added={0} s\u00e9ances ajout\u00e9es +Recent=R\u00e9cents +No\ workouts\ yet.=Aucune s\u00e9ance pour l'instant. +Measured\ by\ a\ strap=Mesur\u00e9 avec une ceinture +Measured\ by\ a\ watch=Mesur\u00e9 avec une montre +{0}\ points={0} points +Workout=S\u00e9ance +PULSE\ MEMBER=MEMBRE PULSE +Due=\u00c0 r\u00e9gler +Cancel\ at\ the\ end\ of\ this\ period=R\u00e9silier \u00e0 la fin de cette p\u00e9riode +Friends=Amis +NEXT\ CLASS=PROCHAIN COURS +Nothing\ booked\ yet=Rien de r\u00e9serv\u00e9 pour l'instant +Pick\ a\ class\ and\ your\ pass\ and\ reminder\ are\ ready.=Choisis un cours et ton pass et ton rappel sont pr\u00eats. +Find\ a\ class=Trouver un cours +for\ {0}=pour {0} +Show\ my\ pass=Afficher mon pass +Details=D\u00e9tails +Top\ level=Niveau maximal +{0}\ week\ streak=S\u00e9rie de {0} semaines +visits=visites +kcal=kcal +Badges\ \ {0}/{1}=Badges {0}/{1} +Wear\ a\ heart\ rate\ strap\ or\ your\ watch\ to\ class\:\ +25\ points\ a\ class\ and\ the\ Strapped\ in\ badge.=Porte une ceinture cardiaque ou ta montre en cours : +25 points par cours et le badge \u00ab Sangle boucl\u00e9e \u00bb. +Morning,\ {0}=Bonjour, {0} +Afternoon,\ {0}=Bon apr\u00e8s-midi, {0} +Evening,\ {0}=Bonsoir, {0} +On\ now=En cours +In\ {0}\ min=Dans {0} min +In\ {0}\ h=Dans {0} h +In\ {0}\ days=Dans {0} jours +{0}\ left={0} restantes +{0}\ waiting={0} en attente +Cancel\ this\ booking=Annuler cette r\u00e9servation +No\ friends\ yet.\ Training\ is\ better\ together.=Pas encore d'amis. S'entra\u00eener est plus agr\u00e9able \u00e0 plusieurs. +Feed=Fil d'actualit\u00e9 +Your\ friends'\ classes\ and\ workouts\ appear\ here.=Les cours et s\u00e9ances de tes amis apparaissent ici. +Add\ a\ friend=Ajouter un ami +Their\ email=Son e-mail +Send\ request=Envoyer la demande +Enter\ their\ email\ address.=Saisis son adresse e-mail. +Request\ sent=Demande envoy\u00e9e +Wants\ to\ be\ friends=Veut \u00eatre ton ami +Asked,\ waiting\ for\ them=Demand\u00e9, en attente de r\u00e9ponse +Getting\ started=D\u00e9butant +Invite=Inviter +Remove\ friend=Retirer l'ami +You\ did\ {1}=Tu as fait {1} +{0}\ did\ {1}={0} a fait {1} +{0}\ kudos={0} bravos +Invite\ {0}=Inviter {0} +Which\ class?=Quel cours ? +You\ are\ going=Tu y vas +{0}\ is\ invited={0} est invit\u00e9 +Pass\ for\ {0}=Pass de {0} +Waiver\ needed\ before\ class=D\u00e9charge requise avant le cours +Queue=Liste d'attente +MM/YY=MM/AA +Choose\ how\ to\ pay.=Choisis le mode de paiement. +Pay\ at\ the\ front\ desk=Payer \u00e0 l'accueil +Live\ view=Vue en direct +Medical\:\ {0}=M\u00e9dical : {0} +First\ visit=Premi\u00e8re visite +{0}\ waiting\ for\ a\ place={0} en attente d'une place +Nobody\ booked\ yet.=Personne n'a encore r\u00e9serv\u00e9. +Marked\ present=Marqu\u00e9 pr\u00e9sent +Marked\ absent=Marqu\u00e9 absent +Heart\ rate\ zones,\ live.\ Zone\ 5\ is\ flat\ out.=Zones de fr\u00e9quence cardiaque, en direct. La zone 5 est \u00e0 fond. +Nobody\ in\ yet=Personne pour l'instant +Members\ appear\ here\ once\ they\ are\ booked.=Les membres apparaissent ici d\u00e8s qu'ils ont r\u00e9serv\u00e9. +Zone\ {0}=Zone {0} +No\ reading=Aucune mesure +No\ classes\ this\ week=Aucun cours cette semaine +Classes\ the\ studio\ gives\ you\ appear\ here.=Les cours que le studio te confie apparaissent ici. +UP\ NEXT=\u00c0 SUIVRE +{0}\ of\ {1}\ booked={0} sur {1} r\u00e9serv\u00e9es +Open\ live\ view=Ouvrir la vue en direct +Tomorrow=Demain +No\ class\ today=Pas de cours aujourd'hui +Mine=Mes services +Withdraw\ offer=Retirer l'offre +Offer\ swap=Proposer un \u00e9change +Offered\ to\ the\ team=Propos\u00e9 \u00e0 l'\u00e9quipe +Offer\ withdrawn=Offre retir\u00e9e +No\ shifts\ to\ come.=Aucun service \u00e0 venir. +Offered\ by\ others=Propos\u00e9s par d'autres +Take=Prendre +The\ shift\ is\ yours=Le service est \u00e0 toi +Nothing\ offered\ right\ now.=Rien n'est propos\u00e9 pour l'instant. +A\ frozen\ plan\ is\ not\ billed\ and\ its\ classes\ cannot\ be\ booked.\ It\ starts\ again\ by\ itself\ on\ the\ last\ day.=Une formule suspendue n'est pas factur\u00e9e et ses cours ne peuvent pas \u00eatre r\u00e9serv\u00e9s. Elle reprend d'elle-m\u00eame le dernier jour. +A\ sample\ gym\ and\ studio\ app\ built\ with\ Codename\ One=Une application d'exemple pour salles de sport et studios, cr\u00e9\u00e9e avec Codename One +Add\ a\ child=Ajouter un enfant +Add\ to\ calendar=Ajouter au calendrier +Added\ to\ your\ calendar=Ajout\u00e9 \u00e0 ton calendrier +After\ every\ payment=Apr\u00e8s chaque paiement +Age\ {0}=\u00c2ge {0} +Ages\ {0}\ to\ {1}=De {0} \u00e0 {1} ans +Allergies,\ asthma,\ an\ injury\ -\ optional=Allergies, asthme, une blessure - facultatif +Amount=Montant +An\ hour\ before\ every\ class\ you\ booked=Une heure avant chaque cours r\u00e9serv\u00e9 +Anything\ the\ coach\ should\ know=Tout ce que le coach doit savoir +Away=Absent +Away\ periods=P\u00e9riodes d'absence +Billing\ paused=Facturation suspendue +Book=R\u00e9server +Booked=R\u00e9serv\u00e9 +Bookings=R\u00e9servations +Calendar\ access\ was\ not\ allowed=L'acc\u00e8s au calendrier n'a pas \u00e9t\u00e9 autoris\u00e9 +Campaigns=Campagnes +Cancelled\ by\ the\ studio=Annul\u00e9 par le studio +Challenges,\ offers\ and\ news=D\u00e9fis, offres et actualit\u00e9s +Check\ in=Enregistrer l'entr\u00e9e +Class=Cours +Class\ is\ full.\ You\ are\ number\ {0}\ in\ the\ queue=Le cours est complet. Tu es num\u00e9ro {0} sur la liste d'attente +Class\ reminders=Rappels de cours +Class\ templates=Mod\u00e8les de cours +Coaching=Coaching +Coming\ up=\u00c0 venir +Could\ not\ add\ it\ to\ your\ calendar=Impossible de l'ajouter \u00e0 ton calendrier +Day\ pass=Pass journ\u00e9e +Desk=Accueil +Find\ a\ member=Trouver un membre +Freeze=Suspendre +Freeze\ the\ plan\ for\ these\ dates=Suspendre la formule sur ces dates +From=Du +Front\ desk=Accueil +Frozen=Suspendu +Full,\ {0}\ waiting=Complet, {0} en attente +Going\ somewhere?\ Tell\ us\ and\ we\ will\ not\ nag\ you.=Tu pars en voyage ? Dis-le-nous et nous ne te relancerons pas. +Guest=Invit\u00e9 +Hold\ the\ fob\ near\ the\ top=Approche le badge du haut +Household=Foyer +I'll\ be\ away=Je serai absent +Inbox=Bo\u00eete de r\u00e9ception +Injured=Bless\u00e9 +Injury=Blessure +Invoices=Factures +Join\ for\ {0}=Rejoindre pour {0} +Kids'\ classes\ are\ by\ age,\ so\ the\ timetable\ shows\ them\ the\ classes\ they\ can\ join.\ You\ sign\ their\ waiver\ next.=Les cours pour enfants sont par \u00e2ge, le planning affiche donc ceux qu'ils peuvent suivre. Tu signes ensuite leur d\u00e9charge. +Legal=Mentions l\u00e9gales +Let\ them\ in=Laisser entrer +Live\ class=Cours en direct +Lookup=Rechercher +Lost\ your\ phone\ or\ fob?\ Replace\ the\ pass=T\u00e9l\u00e9phone ou badge perdu ? Remplace le pass +Manage\ membership=G\u00e9rer l'abonnement +Member=Membre +Membership=Abonnement +Membership\ pass=Pass d'abonn\u00e9 +My\ bookings=Mes r\u00e9servations +My\ classes=Mes cours +Name\ or\ e-mail=Nom ou e-mail +Needed\ for\ reminders\ and\ the\ front\ desk=N\u00e9cessaire pour les rappels et l'accueil +News\ from\ the\ studio\ shows\ up\ here.=Les nouvelles du studio apparaissent ici. +No\ classes=Aucun cours +No\ classes\ attended\ yet.=Aucun cours suivi pour l'instant. +No\ invoices\ yet.=Aucune facture pour l'instant. +No\ plan\ yet.\ Pick\ one\ to\ start\ booking\ classes.=Pas encore de formule. Choisis-en une pour r\u00e9server des cours. +No\ time\ away=Aucune absence +Nobody\ in\ the\ household\ is\ on\ a\ plan\ yet.=Personne dans le foyer n'a encore de formule. +Not\ admitted=Non admis +Nothing\ booked.=Rien de r\u00e9serv\u00e9. +Nothing\ on\ this\ day\ for\ {0}.=Rien ce jour-l\u00e0 pour {0}. +Nothing\ on\ this\ day.=Rien ce jour-l\u00e0. +Nothing\ yet=Rien pour l'instant +Nudges=Rappels +Or\ type\ the\ pass=Ou saisis le pass +Other=Autre +Owner=Propri\u00e9taire +Owner\ mode=Mode propri\u00e9taire +Paid\ by=Pay\u00e9 par +Paid.\ Thank\ you=Pay\u00e9. Merci +Pass=Pass +Pause\ billing=Suspendre la facturation +Pay\ now=Payer maintenant +Payment\ due=Paiement d\u00fb +Plan=Formule +Plans=Formules +Plans\ and\ pricing=Formules et tarifs +Pulse=Pulse +Pulse\ is\ a\ sample.\ No\ real\ classes\ are\ booked,\ no\ studio\ is\ opened\ to\ you\ and\ no\ money\ changes\ hands.=Pulse est un exemple. Aucun vrai cours n'est r\u00e9serv\u00e9, aucun studio ne t'ouvre ses portes et aucun argent n'est \u00e9chang\u00e9. +Queue\ \#{0}=Liste d'attente n\u00b0 {0} +Roster=Liste des inscrits +Scan\ pass=Scanner le pass +Schedule=Planning +See\ plans=Voir les formules +Shifts=Services +Sign\ waiver=Signer la d\u00e9charge +Some\ classes\ are\ for\ an\ age\ band,\ so\ the\ timetable\ needs\ to\ know.\ Children\ are\ added\ later,\ from\ Household.=Certains cours sont r\u00e9serv\u00e9s \u00e0 une tranche d'\u00e2ge, le planning doit donc le savoir. Les enfants s'ajoutent plus tard, depuis Foyer. +Staff=\u00c9quipe +Starting=D\u00e9but +Studio=Studio +Studio\ owner=Propri\u00e9taire du studio +Tap\ fob=Badge NFC +The\ fob\ could\ not\ be\ read=Le badge n'a pas pu \u00eatre lu +The\ scan\ failed.\ Try\ again=Le scan a \u00e9chou\u00e9. R\u00e9essaie +Their\ first\ and\ last\ name=Son pr\u00e9nom et son nom +This\ device\ cannot\ read\ NFC=Cet appareil ne peut pas lire le NFC +This\ device\ has\ no\ calendar\ to\ add\ to=Cet appareil n'a pas de calendrier o\u00f9 ajouter +This\ device\ has\ no\ scanner.\ Type\ the\ pass\ instead=Cet appareil n'a pas de scanner. Saisis le pass \u00e0 la place +Time\ away=Absence +Timetable=Planning +Trainer=Coach +Trainer\ mode=Mode coach +Travel=Voyage +Travelling=En voyage +Travelling\ or\ injured?\ No\ nudges,\ and\ billing\ can\ pause=En voyage ou bless\u00e9 ? Aucun rappel, et la facturation peut \u00eatre suspendue +Trial=Essai +Unfreeze=Reprendre +Unreachable=Injoignable +Until=Jusqu'au +Visit=Visite +Visits\ this\ month=Visites ce mois-ci +Waiting\ for\ the\ next\ member.=En attente du prochain membre. +Welcome\ in=Bienvenue +Welcome\ to\ Pulse=Bienvenue sur Pulse +What\ your\ coaches\ will\ call\ you=Comment tes coachs t'appelleront +Why=Pourquoi +With\ {0}=Avec {0} +Your\ bookings,\ visits,\ household\ and\ settings\ are\ kept\ with\ your\ account\ on\ the\ server\ you\ signed\ in\ to.\ Your\ membership\ pass\ is\ a\ code\ this\ phone\ works\ out\ from\ a\ secret\ that\ server\ gave\ it,\ and\ nothing\ else\ about\ you\ is\ in\ it.=Tes r\u00e9servations, visites, foyer et r\u00e9glages sont conserv\u00e9s avec ton compte sur le serveur auquel tu t'es connect\u00e9. Ton pass d'abonn\u00e9 est un code que ce t\u00e9l\u00e9phone calcule \u00e0 partir d'un secret donn\u00e9 par ce serveur, et il ne contient rien d'autre sur toi. +Your\ name=Ton nom +Your\ old\ pass\ and\ fob\ no\ longer\ open\ the\ door=Ton ancien pass et ton ancien badge n'ouvrent plus la porte +Your\ plan\ ends\ at\ the\ end\ of\ this\ period=Ta formule se termine \u00e0 la fin de cette p\u00e9riode +Your\ plan,\ freezes\ and\ invoices=Ta formule, tes suspensions et tes factures +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Classes\ you\ attended\ stay\ in\ the\ studio's\ records,\ under\ the\ email\ address\ you\ signed\ in\ with.=Ta connexion, ton profil, tes cartes enregistr\u00e9es et tes r\u00e9glages sont supprim\u00e9s d\u00e9finitivement. Les cours suivis restent dans les registres du studio, sous l'adresse e-mail avec laquelle tu t'es connect\u00e9. +ends\ {0}=se termine {0} +for\ the\ follow-up=pour le suivi +frozen\ until\ {0}=suspendu jusqu'au {0} +renews\ {0}=se renouvelle {0} +{0}\ classes\ left=Il reste {0} cours +{0}\ is\ in={0} est entr\u00e9 +{0}\ of\ {1}\ places\ left={0} places libres sur {1} +{0}\ starts\ in\ an\ hour.\ See\ you\ there,\ {1}={0} commence dans une heure. \u00c0 tout \u00e0 l'heure, {1} +{0}\ weeks\ ago=Il y a {0} semaines +1\ friend=1 ami +{0}\ friends={0} amis +Wear\ a\ watch\ or\ a\ strap\ to\ earn\ bonus\ points.=Une s\u00e9ance avec une montre ou une ceinture cardio rapporte des points bonus. +Check-ins=Entr\u00e9es +Walk-ins=Sans r\u00e9servation +Import\ from\ Health=Importer depuis Sant\u00e9 +Joined=Arriv\u00e9s +Left=Partis +Key\ fob=Badge +Pass\ number=Num\u00e9ro du pass +Past\ due=En retard +Monthly\ recurring=R\u00e9current mensuel +Show\ this\ at\ the\ front\ desk.\ It\ refreshes\ every\ {0}\ seconds.=Pr\u00e9sentez-le \u00e0 l'accueil. Il se renouvelle toutes les {0} secondes. +Takings=Encaissements +Visits\ /\ wk=Visites / sem. +Visits\ each\ week=Visites par semaine +{0}\ booked={0} r\u00e9servations +{0}\ bpm\ avg={0} bpm en moyenne +{0}\ more\ to\ level\ {1}=Encore {0} pour le niveau {1} +Jan=janv. +Feb=f\u00e9vr. +Mar=mars +Apr=avr. +May=mai +Jun=juin +Jul=juil. +Aug=ao\u00fbt +Sep=sept. +Oct=oct. +Nov=nov. +Dec=d\u00e9c. + +# The owner's insights, members and billing, and the trainer's month. +Insights=Statistiques +Billing=Facturation +Busy\ hours,\ growth,\ classes\ and\ coaches=Heures de pointe, croissance, cours et coachs +{0}\ at\ risk={0} \u00e0 risque +{0}\ invoices\ past\ due={0} factures en retard +Still\ with\ us\ after\ 90\ days=Toujours l\u00e0 apr\u00e8s 90 jours +Visits\ a\ member\ a\ week=Visites par membre et par semaine +No-shows=Absences +Revenue\ a\ member=Revenu par membre +Busy\ hours=Heures de pointe +Check-ins\ over\ the\ last\ eight\ weeks.=Entr\u00e9es des huit derni\u00e8res semaines. +Classes=Cours +Plan\ mix=R\u00e9partition des formules +Coaches=Coachs +No\ members\ yet.=Pas encore de membres. +No\ classes\ in\ the\ last\ four\ weeks.=Aucun cours ces quatre derni\u00e8res semaines. +{0}%\ full,\ {1}%\ no-show={0}% rempli, {1}% d'absences +{0}\:\ {1}%\ full={0} : {1}% rempli +No\ plans\ sold\ yet.=Aucune formule vendue pour l'instant. +{0}\:\ {1}\ members={0} : {1} membres +{0}\ members={0} membres +{0}\ a\ month={0} par mois +No\ classes\ taught\ in\ the\ last\ four\ weeks.=Aucun cours donn\u00e9 ces quatre derni\u00e8res semaines. +{0}\ classes,\ {1}%\ full,\ {2}%\ show\ up={0} cours, {1}% rempli, {2}% pr\u00e9sents +Mon=Lun +Tue=Mar +Wed=Mer +Thu=Jeu +Fri=Ven +Sat=Sam +Sun=Dim +No\ visits\ yet=Pas encore de visites +Busiest\:\ {0}\ at\ {1}\:00=Plus fr\u00e9quent\u00e9 : {0} \u00e0 {1}:00 +No\ members\ yet=Pas encore de membres +{0}\ active\ members,\ {1}\ twelve\ weeks\ ago={0} membres actifs, {1} il y a douze semaines +Search\ by\ name=Rechercher par nom +All=Tous +{0}\ in\ 30\ days={0} en 30 jours +Nobody\ by\ that\ name=Personne de ce nom +No\ members\ here=Aucun membre ici +Try\ another\ filter.=Essayez un autre filtre. +Never\ in=Jamais venu +In\ today=Venu aujourd'hui +Yesterday=Hier +{0}\ days\ ago=il y a {0} jours +Joined\ {0}=Inscrit le {0} +Visits\ in\ 30\ days=Visites en 30 jours +Week\ streak=Semaines d'affil\u00e9e +Lifetime\ value=Valeur totale +Visits\ a\ week=Visites par semaine +11\ weeks\ ago=Il y a 11 semaines +Favourite\ classes=Cours pr\u00e9f\u00e9r\u00e9s +{0}\ visits={0} visites +No\ classes\ yet.=Pas encore de cours. +No\ invoices=Aucune facture +Failed=\u00c9chou\u00e9e +Open=Ouverte +Void=Annuler +Collected\ in\ 30\ days=Encaiss\u00e9 en 30 jours +Outstanding=Impay\u00e9 +Attempt\ {0},\ next\ {1}=Tentative {0}, prochaine {1} +Attempt\ {0},\ no\ more\ retries=Tentative {0}, plus de relance +Void\ invoice=Annuler la facture +{0}\ for\ {1}\ will\ not\ be\ charged.\ A\ membership\ it\ held\ up\ runs\ again.={0} pour {1} ne sera pas pr\u00e9lev\u00e9. Une adh\u00e9sion bloqu\u00e9e reprend. +Invoice\ voided=Facture annul\u00e9e +Your\ month=Votre mois +Places\ filled=Places occup\u00e9es +Showed\ up=Pr\u00e9sents +Regulars=Habitu\u00e9s +Kudos=Bravos +Check-ins\ a\ week=Passages par semaine +7\ weeks\ ago=Il y a 7 semaines +Top\ classes=Cours phares +{0}\ more\ than\ twelve\ weeks\ ago={0} de plus qu’il y a douze semaines +{0}\ fewer\ than\ twelve\ weeks\ ago={0} de moins qu’il y a douze semaines +Paid\ per\ pack=Payé par carnet +Full=Complet +Your\ game=Votre jeu +Leaderboard=Classement +You=Vous +Rank\ {0}\ of\ {1}=Rang {0} sur {1} +10\ points\ a\ check-in,\ 5\ a\ kudos\ and\ 25\ a\ full\ class.=10 points par passage, 5 par bravo et 25 par cours complet. +{0}\ check-ins={0} passages +Full\ house=Salle comble +Packed\ month=Mois complet +Ten\ regulars=Dix habitu\u00e9s +Crowd\ favourite=Chouchou du public +No\ empty\ mats=Aucun tapis vide +Eight-week\ run=Huit semaines d'affil\u00e9e +Hundred\ club=Club des cent +Top\ coach=Meilleur coach +Fill\ every\ place\ in\ a\ class.=Remplissez toutes les places d'un cours. +Fill\ five\ classes\ in\ four\ weeks.=Remplissez cinq cours en quatre semaines. +Have\ ten\ members\ come\ to\ three\ or\ more\ of\ your\ classes\ in\ four\ weeks.=Dix membres viennent \u00e0 trois de vos cours ou plus en quatre semaines. +Collect\ 25\ kudos\ on\ workouts\ from\ your\ classes\ in\ four\ weeks.=Recueillez 25 bravos sur les s\u00e9ances de vos cours en quatre semaines. +Have\ nine\ in\ ten\ booked\ members\ show\ up,\ over\ twenty\ or\ more\ check-ins.=Neuf membres inscrits sur dix viennent, sur vingt passages ou plus. +Teach\ every\ week\ for\ eight\ weeks\ running.=Donnez cours chaque semaine pendant huit semaines. +Coach\ a\ hundred\ check-ins\ in\ eight\ weeks.=Encadrez cent passages en huit semaines. +Coach\ more\ people\ than\ anyone\ in\ the\ studio\ over\ four\ weeks.=Encadrez plus de monde que quiconque au studio en quatre semaines. +Accounts=Comptes +Search\ by\ name\ or\ e-mail=Rechercher par nom ou e-mail +Flagged=Signal\u00e9 +Blocked=Bloqu\u00e9 +No\ accounts\ here=Aucun compte ici +not\ verified=non v\u00e9rifi\u00e9 +Roles=R\u00f4les +Visits=Visites +Spent=D\u00e9pens\u00e9 +Teaches\ classes,\ sees\ rosters\ and\ the\ live\ view.=Donne des cours, voit les listes et la vue en direct. +Checks\ people\ in\ at\ the\ door\ and\ takes\ walk-ins.=Enregistre les entr\u00e9es et accueille les visiteurs. +Runs\ the\ studio\:\ everything\ on\ these\ screens.=G\u00e8re le studio : tout ce qui est sur ces \u00e9crans. +No\ waiver=Sans d\u00e9charge +Nothing\ done\ to\ this\ account\ yet.=Rien n'a encore \u00e9t\u00e9 fait sur ce compte. +Clear\ the\ flag=Retirer le signalement +Flag\ for\ a\ closer\ look=Signaler pour examen +Flag\ account=Signaler le compte +Other\ owners\ see\ the\ flag\ and\ why.\ The\ account\ keeps\ working.=Les autres propri\u00e9taires voient le signalement et sa raison. Le compte reste actif. +Flag=Signaler +Lift\ the\ block=Lever le blocage +Block\ account=Bloquer le compte +They\ are\ signed\ out\ at\ once\ and\ cannot\ sign\ in,\ book\ or\ check\ in\ until\ the\ block\ is\ lifted.=La personne est d\u00e9connect\u00e9e aussit\u00f4t et ne peut ni se connecter, ni r\u00e9server, ni entrer jusqu'\u00e0 la lev\u00e9e du blocage. +Block=Bloquer +Block\ lifted=Blocage lev\u00e9 +Flag\ cleared=Signalement retir\u00e9 +Roles\ changed=R\u00f4les modifi\u00e9s +Approved=Approuv\u00e9 +Rejected=Refus\u00e9 +Withdraw=Retirer +Account\ holder=Titulaire du compte +Child=Enfant diff --git a/scripts/pulse/common/src/main/l10n/Bundle_he.properties b/scripts/pulse/common/src/main/l10n/Bundle_he.properties new file mode 100644 index 00000000000..4877ba0699a --- /dev/null +++ b/scripts/pulse/common/src/main/l10n/Bundle_he.properties @@ -0,0 +1,672 @@ +# Pulse in Hebrew. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +# These translations were generated automatically as a sample and have not +# been reviewed by a native speaker. Review them before shipping. +@rtl=true +language.name=\u05e2\u05d1\u05e8\u05d9\u05ea + +Dashboard=\u05dc\u05d5\u05d7 \u05d1\u05e7\u05e8\u05d4 +Today=\u05d4\u05d9\u05d5\u05dd +Verified=\u05de\u05d0\u05d5\u05de\u05ea +This\ week=\u05d4\u05e9\u05d1\u05d5\u05e2 +Accept=\u05e7\u05d1\u05dc +Decline=\u05d3\u05d7\u05d4 +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d9\u05e2 \u05dc\u05e9\u05e8\u05ea. \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8. +Please\ sign\ in\ again.=\u05e0\u05d0 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05de\u05d7\u05d3\u05e9. +Your\ account\ is\ not\ allowed\ to\ do\ that.=\u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da \u05d0\u05d9\u05df \u05d4\u05e8\u05e9\u05d0\u05d4 \u05dc\u05db\u05da. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05df \u05e8\u05d2\u05e2 \u05d5\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05df \u05d3\u05e7\u05d4 \u05d5\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Something\ went\ wrong\ ({0}).\ Try\ again.=\u05de\u05e9\u05d4\u05d5 \u05d4\u05e9\u05ea\u05d1\u05e9 ({0}). \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=\u05d4\u05d3\u05d5\u05d0"\u05dc \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d9\u05e0\u05dd \u05ea\u05d5\u05d0\u05de\u05d9\u05dd \u05dc\u05d7\u05e9\u05d1\u05d5\u05df. +Signing\ in\ failed.\ Try\ again.=\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4. \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Home=\u05d1\u05d9\u05ea +Payment=\u05ea\u05e9\u05dc\u05d5\u05dd +Cash=\u05de\u05d6\u05d5\u05de\u05df +Card=\u05db\u05e8\u05d8\u05d9\u05e1 +Message=\u05d4\u05d5\u05d3\u05e2\u05d4 +Not\ now=\u05dc\u05d0 \u05e2\u05db\u05e9\u05d9\u05d5 +Switch\ to=\u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc +Settings=\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Sign\ out=\u05d4\u05ea\u05e0\u05ea\u05e7 +No\ connection=\u05d0\u05d9\u05df \u05d7\u05d9\u05d1\u05d5\u05e8 +The\ server\ at\ {0}\ did\ not\ answer.=\u05d4\u05e9\u05e8\u05ea \u05d1\u05db\u05ea\u05d5\u05d1\u05ea {0} \u05dc\u05d0 \u05e2\u05e0\u05d4. +Try\ again=\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1 +Server\ address=\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05e9\u05e8\u05ea +Create\ an\ account=\u05e6\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df +Name=\u05e9\u05dd +E-mail=\u05d3\u05d5\u05d0"\u05dc +Mobile\ number=\u05de\u05e1\u05e4\u05e8 \u05e0\u05d9\u05d9\u05d3 +Password=\u05e1\u05d9\u05e1\u05de\u05d4 +At\ least\ 8\ characters=\u05dc\u05e4\u05d7\u05d5\u05ea 8 \u05ea\u05d5\u05d5\u05d9\u05dd +Create\ account=\u05e6\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df +Server=\u05e9\u05e8\u05ea +Address=\u05db\u05ea\u05d5\u05d1\u05ea +Save=\u05e9\u05de\u05d5\u05e8 +Account=\u05d7\u05e9\u05d1\u05d5\u05df +Verify=\u05d0\u05de\u05ea +Pay\ with=\u05ea\u05e9\u05dc\u05d5\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea +Notifications=\u05d4\u05ea\u05e8\u05d0\u05d5\u05ea +Receipts=\u05e7\u05d1\u05dc\u05d5\u05ea +Offers=\u05de\u05d1\u05e6\u05e2\u05d9\u05dd +Appearance=\u05de\u05e8\u05d0\u05d4 +Language=\u05e9\u05e4\u05d4 +Theme=\u05e2\u05e8\u05db\u05ea \u05e0\u05d5\u05e9\u05d0 +System=\u05de\u05e2\u05e8\u05db\u05ea +Light=\u05d1\u05d4\u05d9\u05e8 +Dark=\u05db\u05d4\u05d4 +Distances=\u05de\u05e8\u05d7\u05e7\u05d9\u05dd +Kilometres=\u05e7\u05d9\u05dc\u05d5\u05de\u05d8\u05e8\u05d9\u05dd +Miles=\u05de\u05d9\u05d9\u05dc\u05d9\u05dd +About=\u05d0\u05d5\u05d3\u05d5\u05ea +Terms\ of\ service=\u05ea\u05e0\u05d0\u05d9 \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea +Privacy\ policy=\u05de\u05d3\u05d9\u05e0\u05d9\u05d5\u05ea \u05e4\u05e8\u05d8\u05d9\u05d5\u05ea +Sign\ in=\u05d4\u05ea\u05d7\u05d1\u05e8 +Enter\ your\ e-mail\ and\ password.=\u05d4\u05d6\u05df \u05d3\u05d5\u05d0"\u05dc \u05d5\u05e1\u05d9\u05e1\u05de\u05d4. +{0}\ ft={0} \u05e8\u05d2\u05dc +{0}\ mi={0} \u05de\u05d9\u05d9\u05dc +{0}\ m={0} \u05de' +{0}\ km={0} \u05e7"\u05de +{0}\ min={0} \u05d3\u05e7' +Verify\ your\ number=\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05de\u05e1\u05e4\u05e8 +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=\u05e9\u05dc\u05d7\u05e0\u05d5 \u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05dc\u05d8\u05dc\u05e4\u05d5\u05df \u05e9\u05dc\u05da. +We\ sent\ a\ six-digit\ code\ to\ {0}.=\u05e9\u05dc\u05d7\u05e0\u05d5 \u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05d0\u05dc {0}. +Code=\u05e7\u05d5\u05d3 +Send\ a\ new\ code=\u05e9\u05dc\u05d7 \u05e7\u05d5\u05d3 \u05d7\u05d3\u05e9 + +# Onboarding applications, payments, the admin console and the account. +A\ sentence\ the\ record\ will\ keep=\u05de\u05e9\u05e4\u05d8 \u05e9\u05d9\u05d9\u05e9\u05de\u05e8 \u05d1\u05e8\u05e9\u05d5\u05de\u05d4 +About\ you=\u05e2\u05dc\u05d9\u05da +Add\ a\ card=\u05d4\u05d5\u05e1\u05e4\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 +Back=\u05d7\u05d6\u05e8\u05d4 +Cancelled=\u05d1\u05d5\u05d8\u05dc\u05d5 +Card\ number=\u05de\u05e1\u05e4\u05e8 \u05db\u05e8\u05d8\u05d9\u05e1 +Change\ password=\u05e9\u05d9\u05e0\u05d5\u05d9 \u05e1\u05d9\u05e1\u05de\u05d4 +Change\ the\ password\ you\ sign\ in\ with=\u05e9\u05d9\u05e0\u05d5\u05d9 \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d0\u05d9\u05ea\u05d4 \u05e0\u05db\u05e0\u05e1\u05d9\u05dd +Colour=\u05e6\u05d1\u05e2 +Current\ password=\u05e1\u05d9\u05e1\u05de\u05d4 \u05e0\u05d5\u05db\u05d7\u05d9\u05ea +Date\ of\ birth=\u05ea\u05d0\u05e8\u05d9\u05da \u05dc\u05d9\u05d3\u05d4 +Default=\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc +Delete\ account=\u05de\u05d7\u05d9\u05e7\u05ea \u05d7\u05e9\u05d1\u05d5\u05df +Delete\ my\ account=\u05de\u05d7\u05d9\u05e7\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05d9 +Delete\ your\ account?=\u05dc\u05de\u05d7\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df? +Everyone=\u05db\u05d5\u05dc\u05dd +Expires=\u05ea\u05d5\u05e7\u05e3 +Expires\ {0}=\u05d1\u05ea\u05d5\u05e7\u05e3 \u05e2\u05d3 {0} +History=\u05d4\u05d9\u05e1\u05d8\u05d5\u05e8\u05d9\u05d4 +I\ have\ finished=\u05e1\u05d9\u05d9\u05de\u05ea\u05d9 +Keep\ my\ account=\u05dc\u05d4\u05e9\u05d0\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df +Name\ on\ the\ card=\u05d4\u05e9\u05dd \u05e9\u05e2\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +New\ password=\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3\u05e9\u05d4 +New\ password,\ again=\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3\u05e9\u05d4, \u05e9\u05d5\u05d1 +Open\ the\ secure\ page=\u05e4\u05ea\u05d9\u05d7\u05ea \u05d4\u05d3\u05e3 \u05d4\u05de\u05d0\u05d5\u05d1\u05d8\u05d7 +Paid=\u05e9\u05d5\u05dc\u05dd +Profile=\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc +Reason=\u05e1\u05d9\u05d1\u05d4 +Remove\ this\ card=\u05d4\u05e1\u05e8\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 \u05d6\u05d4 +Save\ card=\u05e9\u05de\u05d9\u05e8\u05ea \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Say\ why,\ in\ a\ few\ words.=\u05d9\u05e9 \u05dc\u05e6\u05d9\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05d1\u05d4 \u05d1\u05db\u05de\u05d4 \u05de\u05d9\u05dc\u05d9\u05dd. +Security\ code=\u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4 +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=\u05d4\u05e7\u05d9\u05e9\u05d5 \u05e2\u05dc \u05db\u05e8\u05d8\u05d9\u05e1 \u05db\u05d3\u05d9 \u05e9\u05d9\u05e9\u05de\u05e9 \u05dc\u05ea\u05e9\u05dc\u05d5\u05dd \u05d0\u05dc\u05d0 \u05d0\u05dd \u05ea\u05d1\u05d7\u05e8\u05d5 \u05d0\u05d7\u05e8\u05ea. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05e4\u05e8 \u05db\u05e8\u05d8\u05d9\u05e1. \u05d1\u05d3\u05e7\u05d5 \u05d0\u05ea \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=\u05e7\u05d5\u05d3 \u05d4\u05d0\u05d1\u05d8\u05d7\u05d4 \u05d4\u05d5\u05d0 \u05e9\u05dc\u05d5\u05e9 \u05d0\u05d5 \u05d0\u05e8\u05d1\u05e2 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05e9\u05e2\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1. +The\ two\ new\ passwords\ are\ not\ the\ same.=\u05e9\u05ea\u05d9 \u05d4\u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d4\u05d7\u05d3\u05e9\u05d5\u05ea \u05d0\u05d9\u05e0\u05df \u05d6\u05d4\u05d5\u05ea. +This\ month=\u05d4\u05d7\u05d5\u05d3\u05e9 +Wallet=\u05d0\u05e8\u05e0\u05e7 +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=\u05d9\u05e9 \u05dc\u05db\u05ea\u05d5\u05d1 \u05d0\u05ea \u05d4\u05ea\u05d5\u05e7\u05e3 \u05db\u05d7\u05d5\u05d3\u05e9 \u05d5\u05e9\u05e0\u05d4: 04/28. +Your\ card\ goes\ straight\ to\ the\ bank=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05dc\u05da \u05e2\u05d5\u05d1\u05e8 \u05d9\u05e9\u05d9\u05e8\u05d5\u05ea \u05dc\u05d1\u05e0\u05e7 +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd \u05e9\u05dc\u05da, \u05d5\u05de\u05d9 \u05de\u05d4\u05dd \u05de\u05e9\u05dc\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc +Your\ password\ was\ changed=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d5\u05e0\u05ea\u05d4 +Usage\ statistics=\u05e0\u05ea\u05d5\u05e0\u05d9 \u05e9\u05d9\u05de\u05d5\u05e9 +Help\ improve\ Pulse=\u05e2\u05d6\u05e8\u05d5 \u05dc\u05e9\u05e4\u05e8 \u05d0\u05ea Pulse +No\ personal\ details=\u05dc\u05dc\u05d0 \u05e4\u05e8\u05d8\u05d9\u05dd \u05d0\u05d9\u05e9\u05d9\u05d9\u05dd +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=\u05dc\u05e2\u05d5\u05dc\u05dd \u05dc\u05d0 \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05d3\u05d5\u05d0"\u05dc, \u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d0\u05d5 \u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d5 \u05dc\u05db\u05d1\u05d5\u05ea \u05d1\u05db\u05dc \u05e2\u05ea \u05d1\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Share\ usage\ statistics=\u05e9\u05d9\u05ea\u05d5\u05e3 \u05e0\u05ea\u05d5\u05e0\u05d9 \u05e9\u05d9\u05de\u05d5\u05e9 + +# Ordering, kitchens and deliveries +Active=\u05e4\u05e2\u05d9\u05dc +Add=\u05d4\u05d5\u05e1\u05e3 +Description=\u05ea\u05d9\u05d0\u05d5\u05e8 +Free=\u05d7\u05d9\u05e0\u05dd +Hours=\u05e9\u05e2\u05d5\u05ea +Menu=\u05ea\u05e4\u05e8\u05d9\u05d8 +New=\u05d7\u05d3\u05e9 +Price=\u05de\u05d7\u05d9\u05e8 +Privacy=\u05e4\u05e8\u05d8\u05d9\u05d5\u05ea +Register=\u05d4\u05e8\u05e9\u05de\u05d4 +Remove=\u05d4\u05e1\u05e8\u05d4 +The\ address\ of\ the\ Pulse\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=\u05db\u05ea\u05d5\u05d1\u05ea \u05e9\u05e8\u05ea Pulse \u05e9\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e4\u05d5\u05e0\u05d4 \u05d0\u05dc\u05d9\u05d5. \u05e9\u05d9\u05e0\u05d5\u05d9 \u05e9\u05dc\u05d4 \u05d9\u05e0\u05ea\u05e7 \u05d0\u05d5\u05ea\u05da. +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=\u05d6\u05d4\u05d5 \u05e9\u05e8\u05ea \u05e4\u05d9\u05ea\u05d5\u05d7, \u05d5\u05dc\u05db\u05df \u05dc\u05d0 \u05e0\u05e9\u05dc\u05d7\u05d4 \u05d4\u05d5\u05d3\u05e2\u05d4. \u05d4\u05e7\u05d5\u05d3 \u05e9\u05dc\u05da \u05d4\u05d5\u05d0 {0}. +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Pulse\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=\u05d3\u05e3 \u05e1\u05e4\u05e7 \u05d4\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7 \u05d1\u05d3\u05e4\u05d3\u05e4\u05df. Pulse \u05d0\u05d9\u05e0\u05d4 \u05e8\u05d5\u05d0\u05d4 \u05d0\u05ea \u05d4\u05de\u05e1\u05e4\u05e8. \u05d7\u05d6\u05e8\u05d5 \u05dc\u05db\u05d0\u05df \u05d1\u05e1\u05d9\u05d5\u05dd. +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=\u05e9\u05e8\u05ea \u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e7\u05d1\u05dc \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05d0\u05de\u05d9\u05ea\u05d9\u05d9\u05dd. \u05d4\u05e9\u05ea\u05de\u05e9\u05d5 \u05d1\u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d1\u05d3\u05d9\u05e7\u05d4 4242 4242 4242 4242 \u05e2\u05dd \u05ea\u05d0\u05e8\u05d9\u05da \u05e2\u05ea\u05d9\u05d3\u05d9 \u05db\u05dc\u05e9\u05d4\u05d5 \u05d5\u05e9\u05dc\u05d5\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05db\u05dc\u05e9\u05d4\u05df. +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=\u05e9\u05d9\u05ea\u05d5\u05e3 \u05d4\u05de\u05e1\u05db\u05d9\u05dd \u05d5\u05d4\u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05e9\u05d1\u05e9\u05d9\u05de\u05d5\u05e9. \u05dc\u05e2\u05d5\u05dc\u05dd \u05dc\u05d0 \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05e9\u05dc\u05da \u05d0\u05d5 \u05e4\u05e8\u05d8\u05d9 \u05d4\u05ea\u05e9\u05dc\u05d5\u05dd. +Pulse\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Pulse \u05d9\u05db\u05d5\u05dc\u05d4 \u05dc\u05e1\u05e4\u05d5\u05e8 \u05d0\u05d9\u05dc\u05d5 \u05de\u05e1\u05db\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7\u05d9\u05dd \u05d5\u05d1\u05d0\u05d9\u05dc\u05d5 \u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05ea\u05de\u05e9\u05d9\u05dd, \u05db\u05d3\u05d9 \u05e9\u05e0\u05d5\u05db\u05dc \u05dc\u05e8\u05d0\u05d5\u05ea \u05de\u05d4 \u05e2\u05d5\u05d1\u05d3 \u05d5\u05dc\u05ea\u05e7\u05df \u05d0\u05ea \u05de\u05d4 \u05e9\u05dc\u05d0. +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=\u05db\u05e9\u05e0\u05ea\u05d5\u05e0\u05d9 \u05d4\u05e9\u05d9\u05de\u05d5\u05e9 \u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd, \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e1\u05d5\u05e4\u05e8\u05ea \u05d2\u05dd \u05d0\u05d9\u05dc\u05d5 \u05de\u05e1\u05db\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7\u05d9\u05dd \u05d5\u05d1\u05d0\u05d9\u05dc\u05d5 \u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05ea\u05de\u05e9\u05d9\u05dd, \u05d5\u05de\u05d3\u05d5\u05d5\u05d7\u05ea \u05e2\u05dc \u05db\u05da \u05dc\u05e9\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05e0\u05dc\u05d9\u05d8\u05d9\u05e7\u05d4 \u05e9\u05dc Codename One \u05ea\u05d7\u05ea \u05de\u05d6\u05d4\u05d4 \u05d1\u05d3\u05d5\u05d9 \u05e9\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4. \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05d3\u05d5\u05d0"\u05dc, \u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d5\u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05dd \u05d7\u05dc\u05e7 \u05de\u05d6\u05d4, \u05d5\u05db\u05d9\u05d1\u05d5\u05d9 \u05d1\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e4\u05e1\u05d9\u05e7 \u05d6\u05d0\u05ea. +The\ server\ changed.\ Sign\ in\ again.=\u05d4\u05e9\u05e8\u05ea \u05d4\u05e9\u05ea\u05e0\u05d4. \u05d9\u05e9 \u05dc\u05d4\u05d9\u05db\u05e0\u05e1 \u05e9\u05d5\u05d1. + +First\ class=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d4\u05e8\u05d0\u05e9\u05d5\u05df +Ten\ classes=\u05e2\u05e9\u05e8\u05d4 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Fifty\ classes=\u05d7\u05de\u05d9\u05e9\u05d9\u05dd \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Four-week\ streak=\u05e8\u05e6\u05e3 \u05e9\u05dc \u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +Twelve-week\ streak=\u05e8\u05e6\u05e3 \u05e9\u05dc \u05e9\u05e0\u05d9\u05dd \u05e2\u05e9\u05e8 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +Heart\ tracked=\u05d3\u05d5\u05e4\u05e7 \u05e0\u05de\u05d3\u05d3 +Strapped\ in=\u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7 +Zone\ 5=\u05d0\u05d6\u05d5\u05e8 5 +Ten\ hours=\u05e2\u05e9\u05e8 \u05e9\u05e2\u05d5\u05ea +Come\ to\ your\ first\ class.=\u05d1\u05d5\u05d0\u05d5 \u05dc\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d4\u05e8\u05d0\u05e9\u05d5\u05df \u05e9\u05dc\u05db\u05dd. +Come\ to\ ten\ classes.=\u05d1\u05d5\u05d0\u05d5 \u05dc\u05e2\u05e9\u05e8\u05d4 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd. +Come\ to\ fifty\ classes.=\u05d1\u05d5\u05d0\u05d5 \u05dc\u05d7\u05de\u05d9\u05e9\u05d9\u05dd \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd. +Train\ every\ week\ for\ four\ weeks\ running.=\u05d4\u05ea\u05d0\u05de\u05e0\u05d5 \u05db\u05dc \u05e9\u05d1\u05d5\u05e2 \u05d1\u05de\u05e9\u05da \u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d1\u05e8\u05e6\u05d9\u05e4\u05d5\u05ea. +Train\ every\ week\ for\ twelve\ weeks\ running.=\u05d4\u05ea\u05d0\u05de\u05e0\u05d5 \u05db\u05dc \u05e9\u05d1\u05d5\u05e2 \u05d1\u05de\u05e9\u05da \u05e9\u05e0\u05d9\u05dd \u05e2\u05e9\u05e8 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d1\u05e8\u05e6\u05d9\u05e4\u05d5\u05ea. +Record\ a\ workout\ with\ your\ heart\ rate.=\u05ea\u05e2\u05d3\u05d5 \u05d0\u05d9\u05de\u05d5\u05df \u05e2\u05dd \u05d4\u05d3\u05d5\u05e4\u05e7 \u05e9\u05dc\u05db\u05dd. +Wear\ a\ heart-rate\ strap\ or\ a\ watch\ in\ a\ class.=\u05e9\u05d0\u05d5 \u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7 \u05d0\u05d5 \u05e9\u05e2\u05d5\u05df \u05d1\u05e9\u05d9\u05e2\u05d5\u05e8. +Spend\ a\ minute\ flat\ out,\ in\ zone\ 5.=\u05d4\u05d9\u05e9\u05d0\u05e8\u05d5 \u05d3\u05e7\u05d4 \u05d1\u05de\u05dc\u05d5\u05d0 \u05d4\u05e2\u05d5\u05e6\u05de\u05d4, \u05d1\u05d0\u05d6\u05d5\u05e8 5. +Record\ ten\ hours\ of\ workouts.=\u05ea\u05e2\u05d3\u05d5 \u05e2\u05e9\u05e8 \u05e9\u05e2\u05d5\u05ea \u05d0\u05d9\u05de\u05d5\u05df. +{0},\ not\ earned\ yet={0}, \u05d8\u05e8\u05dd \u05d4\u05d5\u05e9\u05d2 +Earned=\u05d4\u05d5\u05e9\u05d2 +Not\ earned\ yet=\u05d8\u05e8\u05dd \u05d4\u05d5\u05e9\u05d2 +YYYY-MM-DD=YYYY-MM-DD +zone\ {0}\:\ {1}\ min=\u05d0\u05d6\u05d5\u05e8 {0}: {1} \u05d3\u05e7\u05f3 +Your\ studio\ in\ your\ pocket.=\u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 \u05e9\u05dc\u05db\u05dd \u05d1\u05db\u05d9\u05e1. +Book\ classes\ for\ you\ and\ your\ family\ in\ two\ taps=\u05d4\u05d6\u05de\u05d9\u05e0\u05d5 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05dc\u05db\u05dd \u05d5\u05dc\u05de\u05e9\u05e4\u05d7\u05d4 \u05d1\u05e9\u05ea\u05d9 \u05e0\u05d2\u05d9\u05e2\u05d5\u05ea +Walk\ in\ with\ a\ pass\ on\ your\ phone\ or\ your\ watch=\u05d4\u05d9\u05db\u05e0\u05e1\u05d5 \u05e2\u05dd \u05db\u05e8\u05d8\u05d9\u05e1 \u05db\u05e0\u05d9\u05e1\u05d4 \u05d1\u05d8\u05dc\u05e4\u05d5\u05df \u05d0\u05d5 \u05d1\u05e9\u05e2\u05d5\u05df +Earn\ points\ and\ badges\ for\ every\ class\ you\ show\ up\ to=\u05e6\u05d1\u05e8\u05d5 \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea \u05d5\u05ea\u05d2\u05d9\u05dd \u05e2\u05dc \u05db\u05dc \u05e9\u05d9\u05e2\u05d5\u05e8 \u05e9\u05d0\u05ea\u05dd \u05de\u05d2\u05d9\u05e2\u05d9\u05dd \u05d0\u05dc\u05d9\u05d5 +Walk-in=\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc\u05dc\u05d0 \u05d4\u05d6\u05de\u05e0\u05d4 +Just\ in=\u05d4\u05d2\u05d9\u05e2\u05d5 \u05db\u05e8\u05d2\u05e2 +Checked\ in=\u05e0\u05e8\u05e9\u05dd \u05db\u05e0\u05d9\u05e1\u05d4 +In=\u05e0\u05db\u05e0\u05e1 +Turned\ away=\u05e0\u05d3\u05d7\u05d4 +Monthly=\u05d7\u05d5\u05d3\u05e9\u05d9 +Annual=\u05e9\u05e0\u05ea\u05d9 +Class\ pack=\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05d9\u05ea \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Drop-in=\u05db\u05e0\u05d9\u05e1\u05d4 \u05d1\u05d5\u05d3\u05d3\u05ea +New\ plan=\u05de\u05e0\u05d5\u05d9 \u05d7\u05d3\u05e9 +Retired=\u05d4\u05d5\u05e6\u05d0 \u05de\u05e9\u05d9\u05de\u05d5\u05e9 +Unlimited\ monthly=\u05d7\u05d5\u05d3\u05e9\u05d9 \u05dc\u05dc\u05d0 \u05d4\u05d2\u05d1\u05dc\u05d4 +What\ the\ plan\ includes=\u05de\u05d4 \u05db\u05d5\u05dc\u05dc \u05d4\u05de\u05e0\u05d5\u05d9 +Kind=\u05e1\u05d5\u05d2 +Months\ per\ period=\u05d7\u05d5\u05d3\u05e9\u05d9\u05dd \u05dc\u05ea\u05e7\u05d5\u05e4\u05d4 +Classes\ included\ (0\ for\ unlimited)=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05db\u05dc\u05d5\u05dc\u05d9\u05dd (0 \u05dc\u05dc\u05d0 \u05d4\u05d2\u05d1\u05dc\u05d4) +Off-peak\ only=\u05e8\u05e7 \u05d1\u05e9\u05e2\u05d5\u05ea \u05e9\u05e7\u05d8\u05d5\u05ea +On\ sale=\u05dc\u05de\u05db\u05d9\u05e8\u05d4 +A\ plan\ needs\ a\ name\ and\ a\ price.=\u05dc\u05de\u05e0\u05d5\u05d9 \u05e0\u05d3\u05e8\u05e9\u05d9\u05dd \u05e9\u05dd \u05d5\u05de\u05d7\u05d9\u05e8. +Plan\ saved=\u05d4\u05de\u05e0\u05d5\u05d9 \u05e0\u05e9\u05de\u05e8 +{0}\ sent,\ {1}\ opened,\ {2}\ booked=\u05e0\u05e9\u05dc\u05d7\u05d5 {0}, \u05e0\u05e4\u05ea\u05d7\u05d5 {1}, \u05d4\u05d5\u05d6\u05de\u05e0\u05d5 {2} +No\ campaigns\ sent\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e0\u05e9\u05dc\u05d7\u05d5 \u05e7\u05de\u05e4\u05d9\u05d9\u05e0\u05d9\u05dd. +New\ campaign=\u05e7\u05de\u05e4\u05d9\u05d9\u05df \u05d7\u05d3\u05e9 +Class\ regulars=\u05de\u05ea\u05d0\u05de\u05e0\u05d9 \u05e7\u05d1\u05e2 \u05d1\u05e9\u05d9\u05e2\u05d5\u05e8 +Lapsed\ members=\u05d7\u05d1\u05e8\u05d9\u05dd \u05dc\u05d0 \u05e4\u05e2\u05d9\u05dc\u05d9\u05dd +Every\ member=\u05db\u05dc \u05d4\u05d7\u05d1\u05e8\u05d9\u05dd +Audience=\u05e7\u05d4\u05dc \u05d9\u05e2\u05d3 +Lapsed=\u05dc\u05d0 \u05e4\u05e2\u05d9\u05dc\u05d9\u05dd +Name,\ for\ you=\u05e9\u05dd, \u05d1\u05e9\u05d1\u05d9\u05dc\u05db\u05dd +Spin\ Tuesday\ push=\u05d4\u05ea\u05e8\u05d0\u05ea \u05e1\u05e4\u05d9\u05e0\u05d9\u05e0\u05d2 \u05e9\u05dc \u05d9\u05d5\u05dd \u05e9\u05dc\u05d9\u05e9\u05d9 +Title=\u05db\u05d5\u05ea\u05e8\u05ea +A\ place\ is\ waiting=\u05de\u05e7\u05d5\u05dd \u05de\u05d7\u05db\u05d4 \u05dc\u05db\u05dd +Tuesday's\ spin\ has\ room.\ Come\ ride\ with\ us.=\u05d9\u05e9 \u05de\u05e7\u05d5\u05dd \u05d1\u05e1\u05e4\u05d9\u05e0\u05d9\u05e0\u05d2 \u05e9\u05dc \u05d9\u05d5\u05dd \u05e9\u05dc\u05d9\u05e9\u05d9. \u05d1\u05d5\u05d0\u05d5 \u05dc\u05e8\u05db\u05d5\u05d1 \u05d0\u05d9\u05ea\u05e0\u05d5. +Places\ left\ in\ {0}=\u05e0\u05e9\u05d0\u05e8\u05d5 \u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d1{0} +{0}\ on\ {1}\ has\ room\ for\ you.\ Tap\ to\ book.=\u05d1{0} \u05d1{1} \u05d9\u05e9 \u05de\u05e7\u05d5\u05dd \u05d1\u05e9\u05d1\u05d9\u05dc\u05db\u05dd. \u05d4\u05e7\u05d9\u05e9\u05d5 \u05dc\u05d4\u05d6\u05de\u05e0\u05d4. +Win\ back=\u05d4\u05d7\u05d6\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd +We\ miss\ you=\u05d4\u05ea\u05d2\u05e2\u05d2\u05e2\u05e0\u05d5 \u05d0\u05dc\u05d9\u05db\u05dd +Your\ next\ class\ is\ on\ us.\ Tap\ to\ see\ the\ timetable.=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d4\u05d1\u05d0 \u05e2\u05dc\u05d9\u05e0\u05d5. \u05d4\u05e7\u05d9\u05e9\u05d5 \u05db\u05d3\u05d9 \u05dc\u05e8\u05d0\u05d5\u05ea \u05d0\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e9\u05e2\u05d5\u05ea. +Send=\u05e9\u05dc\u05d9\u05d7\u05d4 +Sent\ to\ {0}\ members=\u05e0\u05e9\u05dc\u05d7 \u05dc-{0} \u05d7\u05d1\u05e8\u05d9\u05dd +Schedule\ a\ class=\u05e7\u05d1\u05d9\u05e2\u05ea \u05e9\u05d9\u05e2\u05d5\u05e8 +Nothing\ on\ the\ timetable\ this\ week.=\u05d0\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d1\u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e9\u05e2\u05d5\u05ea \u05d4\u05e9\u05d1\u05d5\u05e2. +When=\u05de\u05ea\u05d9 +Where=\u05d0\u05d9\u05e4\u05d4 +Substitute=\u05de\u05d7\u05dc\u05d9\u05e3 +{0}\ takes\ the\ class={0} \u05de\u05e2\u05d1\u05d9\u05e8 \u05d0\u05ea \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 +Cancel\ class=\u05d1\u05d9\u05d8\u05d5\u05dc \u05e9\u05d9\u05e2\u05d5\u05e8 +Everyone\ booked\ is\ told,\ with\ your\ reason,\ and\ their\ credit\ is\ returned.=\u05db\u05dc \u05d4\u05de\u05d5\u05d6\u05de\u05e0\u05d9\u05dd \u05d9\u05e7\u05d1\u05dc\u05d5 \u05d4\u05d5\u05d3\u05e2\u05d4 \u05e2\u05dd \u05d4\u05e1\u05d9\u05d1\u05d4 \u05e9\u05dc\u05db\u05dd, \u05d5\u05d4\u05e7\u05e8\u05d3\u05d9\u05d8 \u05e9\u05dc\u05d4\u05dd \u05d9\u05d5\u05d7\u05d6\u05e8. +Class\ cancelled=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d1\u05d5\u05d8\u05dc +Add\ a\ class\ template,\ a\ room\ and\ a\ trainer\ first.=\u05d4\u05d5\u05e1\u05d9\u05e4\u05d5 \u05e7\u05d5\u05d3\u05dd \u05ea\u05d1\u05e0\u05d9\u05ea \u05e9\u05d9\u05e2\u05d5\u05e8, \u05d7\u05d3\u05e8 \u05d5\u05de\u05d0\u05de\u05df. +Room=\u05d7\u05d3\u05e8 +Places\ (blank\ for\ the\ template's)=\u05de\u05e7\u05d5\u05de\u05d5\u05ea (\u05e8\u05d9\u05e7 = \u05db\u05de\u05d5 \u05d1\u05ea\u05d1\u05e0\u05d9\u05ea) +Weeks=\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +{0}\ classes\ scheduled=\u05e0\u05e7\u05d1\u05e2\u05d5 {0} \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Members=\u05d7\u05d1\u05e8\u05d9\u05dd +Places\ taken=\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05ea\u05e4\u05d5\u05e1\u05d9\u05dd +At\ risk=\u05d1\u05e1\u05d9\u05db\u05d5\u05df +{0}\ joined\ and\ {1}\ left\ in\ 30\ days;\ {2}\ check-ins\ this\ week,\ {3}\ invoices\ past\ due.={0} \u05d4\u05e6\u05d8\u05e8\u05e4\u05d5 \u05d5-{1} \u05e2\u05d6\u05d1\u05d5 \u05d1-30 \u05d9\u05de\u05d9\u05dd; {2} \u05db\u05e0\u05d9\u05e1\u05d5\u05ea \u05d4\u05e9\u05d1\u05d5\u05e2, {3} \u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05d0\u05d9\u05d7\u05d5\u05e8. +{0}\ in\ the\ last\ 30\ days={0} \u05d1-30 \u05d4\u05d9\u05de\u05d9\u05dd \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd +Popular\ classes=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e4\u05d5\u05e4\u05d5\u05dc\u05e8\u05d9\u05d9\u05dd +No\ bookings\ in\ the\ last\ month.=\u05d0\u05d9\u05df \u05d4\u05d6\u05de\u05e0\u05d5\u05ea \u05d1\u05d7\u05d5\u05d3\u05e9 \u05d4\u05d0\u05d7\u05e8\u05d5\u05df. +Under-filled\ this\ week=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05dc\u05d0 \u05de\u05dc\u05d0\u05d9\u05dd \u05d4\u05e9\u05d1\u05d5\u05e2 +Every\ class\ is\ filling\ up.=\u05db\u05dc \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05de\u05ea\u05de\u05dc\u05d0\u05d9\u05dd. +Win\ back\ {0}\ lapsing\ members=\u05d4\u05d7\u05d6\u05e8\u05ea {0} \u05d7\u05d1\u05e8\u05d9\u05dd \u05e9\u05de\u05ea\u05e8\u05d7\u05e7\u05d9\u05dd +Win\ back\ a\ lapsing\ member=\u05d4\u05d7\u05d6\u05e8\u05ea \u05d7\u05d1\u05e8 \u05e9\u05de\u05ea\u05e8\u05d7\u05e7 +Revenue=\u05d4\u05db\u05e0\u05e1\u05d4 +{0}\ classes\ this\ week={0} \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d4\u05e9\u05d1\u05d5\u05e2 +Roles\ saved=\u05d4\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd \u05e0\u05e9\u05de\u05e8\u05d5 +Offered=\u05d4\u05d5\u05e6\u05e2 +No\ shifts\ on\ the\ rota.=\u05d0\u05d9\u05df \u05de\u05e9\u05de\u05e8\u05d5\u05ea \u05d1\u05dc\u05d5\u05d7. +Add\ a\ shift=\u05d4\u05d5\u05e1\u05e4\u05ea \u05de\u05e9\u05de\u05e8\u05ea +Nobody\ on\ the\ staff\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d0\u05e3 \u05d0\u05d7\u05d3 \u05d1\u05e6\u05d5\u05d5\u05ea. +Who=\u05de\u05d9 +Duty=\u05ea\u05e4\u05e7\u05d9\u05d3 \u05d1\u05de\u05e9\u05de\u05e8\u05ea +Starts=\u05d4\u05ea\u05d7\u05dc\u05d4 +Shift\ added=\u05d4\u05de\u05e9\u05de\u05e8\u05ea \u05e0\u05d5\u05e1\u05e4\u05d4 +New\ template=\u05ea\u05d1\u05e0\u05d9\u05ea \u05d7\u05d3\u05e9\u05d4 +{0}\ places={0} \u05de\u05e7\u05d5\u05de\u05d5\u05ea +intensity\ {0}\ of\ 5=\u05e2\u05e6\u05d9\u05de\u05d5\u05ea {0} \u05de\u05ea\u05d5\u05da 5 +Spin\ 45=\u05e1\u05e4\u05d9\u05e0\u05d9\u05e0\u05d2 45 +What\ the\ class\ is\ like=\u05d0\u05d9\u05da \u05e0\u05e8\u05d0\u05d4 \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 +Category=\u05e7\u05d8\u05d2\u05d5\u05e8\u05d9\u05d4 +Cardio=\u05d0\u05d9\u05e8\u05d5\u05d1\u05d9 +Minutes=\u05d3\u05e7\u05d5\u05ea +minutes=\u05d3\u05e7\u05d5\u05ea +Places=\u05de\u05e7\u05d5\u05de\u05d5\u05ea +Intensity,\ 1\ to\ 5=\u05e2\u05e6\u05d9\u05de\u05d5\u05ea, 1 \u05e2\u05d3 5 +Youngest\ age\ (0\ for\ any)=\u05d2\u05d9\u05dc \u05de\u05d9\u05e0\u05d9\u05de\u05d5\u05dd (0 \u05dc\u05db\u05dc \u05d2\u05d9\u05dc) +Oldest\ age\ (0\ for\ any)=\u05d2\u05d9\u05dc \u05de\u05e7\u05e1\u05d9\u05de\u05d5\u05dd (0 \u05dc\u05db\u05dc \u05d2\u05d9\u05dc) +Bookable=\u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d6\u05de\u05e0\u05d4 +A\ template\ needs\ a\ name.=\u05dc\u05ea\u05d1\u05e0\u05d9\u05ea \u05e0\u05d3\u05e8\u05e9 \u05e9\u05dd. +Template\ saved=\u05d4\u05ea\u05d1\u05e0\u05d9\u05ea \u05e0\u05e9\u05de\u05e8\u05d4 +Workouts=\u05d0\u05d9\u05de\u05d5\u05e0\u05d9\u05dd +Share\ with\ friends=\u05e9\u05d9\u05ea\u05d5\u05e3 \u05e2\u05dd \u05d7\u05d1\u05e8\u05d9\u05dd +RECORDING=\u05de\u05e7\u05dc\u05d9\u05d8 +ON\ NOW=\u05de\u05ea\u05e7\u05d9\u05d9\u05dd \u05db\u05e2\u05ea +Wear\ a\ strap\ or\ your\ watch\ and\ this\ fills\ in.\ Worn\ sensors\ earn\ a\ bonus.=\u05e9\u05d0\u05d5 \u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7 \u05d0\u05d5 \u05e9\u05e2\u05d5\u05df \u05d5\u05d6\u05d4 \u05d9\u05ea\u05de\u05dc\u05d0. \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05e9\u05e0\u05e2\u05e0\u05d3\u05d5 \u05de\u05d6\u05db\u05d9\u05dd \u05d1\u05d1\u05d5\u05e0\u05d5\u05e1. +Record\ it\ to\ log\ the\ workout\ and\ earn\ points\ for\ the\ class.=\u05d4\u05e7\u05dc\u05d9\u05d8\u05d5 \u05db\u05d3\u05d9 \u05dc\u05e8\u05e9\u05d5\u05dd \u05d0\u05ea \u05d4\u05d0\u05d9\u05de\u05d5\u05df \u05d5\u05dc\u05e6\u05d1\u05d5\u05e8 \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea \u05e2\u05dc \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8. +Record\ this\ class=\u05d4\u05e7\u05dc\u05d8\u05ea \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 +Workout\ saved\:\ +{0}\ points=\u05d4\u05d0\u05d9\u05de\u05d5\u05df \u05e0\u05e9\u05de\u05e8: +{0} \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea +Workout\ saved=\u05d4\u05d0\u05d9\u05de\u05d5\u05df \u05e0\u05e9\u05de\u05e8 +End\ workout=\u05e1\u05d9\u05d5\u05dd \u05d0\u05d9\u05de\u05d5\u05df +Health\ data\ could\ not\ be\ read=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05e0\u05ea\u05d5\u05e0\u05d9 \u05d4\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea +{0}\ workouts\ added=\u05e0\u05d5\u05e1\u05e4\u05d5 {0} \u05d0\u05d9\u05de\u05d5\u05e0\u05d9\u05dd +Recent=\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd +No\ workouts\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d0\u05d9\u05de\u05d5\u05e0\u05d9\u05dd. +Measured\ by\ a\ strap=\u05e0\u05de\u05d3\u05d3 \u05d1\u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7 +Measured\ by\ a\ watch=\u05e0\u05de\u05d3\u05d3 \u05d1\u05e9\u05e2\u05d5\u05df +{0}\ points={0} \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea +Workout=\u05d0\u05d9\u05de\u05d5\u05df +PULSE\ MEMBER=\u05d7\u05d1\u05e8 Pulse +Due=\u05dc\u05ea\u05e9\u05dc\u05d5\u05dd +Cancel\ at\ the\ end\ of\ this\ period=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d1\u05ea\u05d5\u05dd \u05d4\u05ea\u05e7\u05d5\u05e4\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea +Friends=\u05d7\u05d1\u05e8\u05d9\u05dd +NEXT\ CLASS=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d4\u05d1\u05d0 +Nothing\ booked\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d4\u05d5\u05d6\u05de\u05df \u05db\u05dc\u05d5\u05dd +Pick\ a\ class\ and\ your\ pass\ and\ reminder\ are\ ready.=\u05d1\u05d7\u05e8\u05d5 \u05e9\u05d9\u05e2\u05d5\u05e8 \u05d5\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05d5\u05d4\u05ea\u05d6\u05db\u05d5\u05e8\u05ea \u05de\u05d5\u05db\u05e0\u05d9\u05dd. +Find\ a\ class=\u05d7\u05d9\u05e4\u05d5\u05e9 \u05e9\u05d9\u05e2\u05d5\u05e8 +for\ {0}=\u05e2\u05d1\u05d5\u05e8 {0} +Show\ my\ pass=\u05d4\u05e6\u05d2\u05ea \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05dc\u05d9 +Details=\u05e4\u05e8\u05d8\u05d9\u05dd +Top\ level=\u05d4\u05e8\u05de\u05d4 \u05d4\u05d2\u05d1\u05d5\u05d4\u05d4 \u05d1\u05d9\u05d5\u05ea\u05e8 +{0}\ week\ streak=\u05e8\u05e6\u05e3 \u05e9\u05dc {0} \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +visits=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd +kcal=\u05e7\u05e7\u05f4\u05dc +Badges\ \ {0}/{1}=\u05ea\u05d2\u05d9\u05dd {0}/{1} +Wear\ a\ heart\ rate\ strap\ or\ your\ watch\ to\ class\:\ +25\ points\ a\ class\ and\ the\ Strapped\ in\ badge.=\u05e9\u05d0\u05d5 \u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7 \u05d0\u05d5 \u05e9\u05e2\u05d5\u05df \u05dc\u05e9\u05d9\u05e2\u05d5\u05e8: +25 \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea \u05dc\u05db\u05dc \u05e9\u05d9\u05e2\u05d5\u05e8 \u05d5\u05ea\u05d2 \u201e\u05d7\u05d2\u05d5\u05e8\u05ea \u05d3\u05d5\u05e4\u05e7\u201c. +Morning,\ {0}=\u05d1\u05d5\u05e7\u05e8 \u05d8\u05d5\u05d1, {0} +Afternoon,\ {0}=\u05e6\u05d4\u05e8\u05d9\u05d9\u05dd \u05d8\u05d5\u05d1\u05d9\u05dd, {0} +Evening,\ {0}=\u05e2\u05e8\u05d1 \u05d8\u05d5\u05d1, {0} +On\ now=\u05de\u05ea\u05e7\u05d9\u05d9\u05dd \u05db\u05e2\u05ea +In\ {0}\ min=\u05d1\u05e2\u05d5\u05d3 {0} \u05d3\u05e7\u05f3 +In\ {0}\ h=\u05d1\u05e2\u05d5\u05d3 {0} \u05e9\u05e2\u05f3 +In\ {0}\ days=\u05d1\u05e2\u05d5\u05d3 {0} \u05d9\u05de\u05d9\u05dd +{0}\ left=\u05e0\u05e9\u05d0\u05e8\u05d5 {0} +{0}\ waiting={0} \u05de\u05de\u05ea\u05d9\u05e0\u05d9\u05dd +Cancel\ this\ booking=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d4\u05d4\u05d6\u05de\u05e0\u05d4 +No\ friends\ yet.\ Training\ is\ better\ together.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d7\u05d1\u05e8\u05d9\u05dd. \u05d9\u05d5\u05ea\u05e8 \u05db\u05d9\u05e3 \u05dc\u05d4\u05ea\u05d0\u05de\u05df \u05d9\u05d7\u05d3. +Feed=\u05e2\u05d3\u05db\u05d5\u05e0\u05d9\u05dd +Your\ friends'\ classes\ and\ workouts\ appear\ here.=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d5\u05d4\u05d0\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05e9\u05dc \u05d4\u05d7\u05d1\u05e8\u05d9\u05dd \u05e9\u05dc\u05db\u05dd \u05d9\u05d5\u05e4\u05d9\u05e2\u05d5 \u05db\u05d0\u05df. +Add\ a\ friend=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d7\u05d1\u05e8 +Their\ email=\u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05dc\u05d5 \u05d0\u05d5 \u05e9\u05dc\u05d4 +Send\ request=\u05e9\u05dc\u05d9\u05d7\u05ea \u05d1\u05e7\u05e9\u05d4 +Enter\ their\ email\ address.=\u05d4\u05d6\u05d9\u05e0\u05d5 \u05d0\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc. +Request\ sent=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e9\u05dc\u05d7\u05d4 +Wants\ to\ be\ friends=\u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d9\u05d5\u05ea \u05d7\u05d1\u05e8 \u05e9\u05dc\u05db\u05dd +Asked,\ waiting\ for\ them=\u05e0\u05e9\u05dc\u05d7\u05d4 \u05d1\u05e7\u05e9\u05d4, \u05de\u05de\u05ea\u05d9\u05df \u05dc\u05ea\u05e9\u05d5\u05d1\u05d4 +Getting\ started=\u05d1\u05ea\u05d7\u05d9\u05dc\u05ea \u05d4\u05d3\u05e8\u05da +Invite=\u05d4\u05d6\u05de\u05e0\u05d4 +Remove\ friend=\u05d4\u05e1\u05e8\u05ea \u05d7\u05d1\u05e8 +You\ did\ {1}=\u05e2\u05e9\u05d9\u05ea\u05dd {1} +{0}\ did\ {1}={0} \u05e2\u05e9\u05d4/\u05e2\u05e9\u05ea\u05d4 {1} +{0}\ kudos={0} \u05d1\u05e8\u05db\u05d5\u05ea +Invite\ {0}=\u05d4\u05d6\u05de\u05e0\u05ea {0} +Which\ class?=\u05d0\u05d9\u05d6\u05d4 \u05e9\u05d9\u05e2\u05d5\u05e8? +You\ are\ going=\u05d0\u05ea\u05dd \u05de\u05d2\u05d9\u05e2\u05d9\u05dd +{0}\ is\ invited={0} \u05d4\u05d5\u05d6\u05de\u05df/\u05d4 +Pass\ for\ {0}=\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05dc {0} +Waiver\ needed\ before\ class=\u05e0\u05d3\u05e8\u05e9\u05ea \u05d4\u05e6\u05d4\u05e8\u05ea \u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05dc\u05e4\u05e0\u05d9 \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 +Queue=\u05e8\u05e9\u05d9\u05de\u05ea \u05d4\u05de\u05ea\u05e0\u05d4 +MM/YY=MM/YY +Choose\ how\ to\ pay.=\u05d1\u05d7\u05e8\u05d5 \u05d0\u05de\u05e6\u05e2\u05d9 \u05ea\u05e9\u05dc\u05d5\u05dd. +Pay\ at\ the\ front\ desk=\u05ea\u05e9\u05dc\u05d5\u05dd \u05d1\u05d3\u05dc\u05e4\u05e7 \u05d4\u05e7\u05d1\u05dc\u05d4 +Live\ view=\u05ea\u05e6\u05d5\u05d2\u05d4 \u05d7\u05d9\u05d4 +Medical\:\ {0}=\u05e8\u05e4\u05d5\u05d0\u05d9: {0} +First\ visit=\u05d1\u05d9\u05e7\u05d5\u05e8 \u05e8\u05d0\u05e9\u05d5\u05df +{0}\ waiting\ for\ a\ place={0} \u05de\u05de\u05ea\u05d9\u05e0\u05d9\u05dd \u05dc\u05de\u05e7\u05d5\u05dd +Nobody\ booked\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05e3 \u05d0\u05d7\u05d3 \u05dc\u05d0 \u05d4\u05d6\u05de\u05d9\u05df. +Marked\ present=\u05e1\u05d5\u05de\u05df \u05db\u05e0\u05d5\u05db\u05d7 +Marked\ absent=\u05e1\u05d5\u05de\u05df \u05db\u05e0\u05e2\u05d3\u05e8 +Heart\ rate\ zones,\ live.\ Zone\ 5\ is\ flat\ out.=\u05d0\u05d6\u05d5\u05e8\u05d9 \u05d3\u05d5\u05e4\u05e7 \u05d1\u05d6\u05de\u05df \u05d0\u05de\u05ea. \u05d0\u05d6\u05d5\u05e8 5 \u05d4\u05d5\u05d0 \u05de\u05dc\u05d5\u05d0 \u05d4\u05e2\u05d5\u05e6\u05de\u05d4. +Nobody\ in\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05e3 \u05d0\u05d7\u05d3 \u05dc\u05d0 \u05e0\u05db\u05e0\u05e1 +Members\ appear\ here\ once\ they\ are\ booked.=\u05d7\u05d1\u05e8\u05d9\u05dd \u05d9\u05d5\u05e4\u05d9\u05e2\u05d5 \u05db\u05d0\u05df \u05dc\u05d0\u05d7\u05e8 \u05e9\u05d9\u05d6\u05de\u05d9\u05e0\u05d5. +Zone\ {0}=\u05d0\u05d6\u05d5\u05e8 {0} +No\ reading=\u05d0\u05d9\u05df \u05e7\u05e8\u05d9\u05d0\u05d4 +No\ classes\ this\ week=\u05d0\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d4\u05e9\u05d1\u05d5\u05e2 +Classes\ the\ studio\ gives\ you\ appear\ here.=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 \u05de\u05e9\u05d1\u05e5 \u05dc\u05db\u05dd \u05d9\u05d5\u05e4\u05d9\u05e2\u05d5 \u05db\u05d0\u05df. +UP\ NEXT=\u05d4\u05d1\u05d0 \u05d1\u05ea\u05d5\u05e8 +{0}\ of\ {1}\ booked={0} \u05de\u05ea\u05d5\u05da {1} \u05d4\u05d5\u05d6\u05de\u05e0\u05d5 +Open\ live\ view=\u05e4\u05ea\u05d9\u05d7\u05ea \u05ea\u05e6\u05d5\u05d2\u05d4 \u05d7\u05d9\u05d4 +Tomorrow=\u05de\u05d7\u05e8 +No\ class\ today=\u05d0\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8 \u05d4\u05d9\u05d5\u05dd +Mine=\u05e9\u05dc\u05d9 +Withdraw\ offer=\u05de\u05e9\u05d9\u05db\u05ea \u05d4\u05d4\u05e6\u05e2\u05d4 +Offer\ swap=\u05d4\u05e6\u05e2\u05ea \u05d4\u05d7\u05dc\u05e4\u05d4 +Offered\ to\ the\ team=\u05d4\u05d5\u05e6\u05e2 \u05dc\u05e6\u05d5\u05d5\u05ea +Offer\ withdrawn=\u05d4\u05d4\u05e6\u05e2\u05d4 \u05e0\u05de\u05e9\u05db\u05d4 +No\ shifts\ to\ come.=\u05d0\u05d9\u05df \u05de\u05e9\u05de\u05e8\u05d5\u05ea \u05e7\u05e8\u05d5\u05d1\u05d5\u05ea. +Offered\ by\ others=\u05d4\u05d5\u05e6\u05e2\u05d5 \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d0\u05d7\u05e8\u05d9\u05dd +Take=\u05dc\u05e7\u05d7\u05ea +The\ shift\ is\ yours=\u05d4\u05de\u05e9\u05de\u05e8\u05ea \u05e9\u05dc\u05db\u05dd +Nothing\ offered\ right\ now.=\u05db\u05e8\u05d2\u05e2 \u05dc\u05d0 \u05de\u05d5\u05e6\u05e2 \u05d3\u05d1\u05e8. +A\ frozen\ plan\ is\ not\ billed\ and\ its\ classes\ cannot\ be\ booked.\ It\ starts\ again\ by\ itself\ on\ the\ last\ day.=\u05de\u05e0\u05d5\u05d9 \u05de\u05d5\u05e7\u05e4\u05d0 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d7\u05d5\u05d9\u05d1 \u05d5\u05d0\u05d9 \u05d0\u05e4\u05e9\u05e8 \u05dc\u05d4\u05d6\u05de\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d1\u05de\u05e1\u05d2\u05e8\u05ea\u05d5. \u05d4\u05d5\u05d0 \u05d9\u05d7\u05d5\u05d3\u05e9 \u05de\u05e2\u05e6\u05de\u05d5 \u05d1\u05d9\u05d5\u05dd \u05d4\u05d0\u05d7\u05e8\u05d5\u05df. +A\ sample\ gym\ and\ studio\ app\ built\ with\ Codename\ One=\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d3\u05d5\u05d2\u05de\u05d4 \u05dc\u05d7\u05d3\u05e8\u05d9 \u05db\u05d5\u05e9\u05e8 \u05d5\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5, \u05e9\u05e0\u05d1\u05e0\u05ea\u05d4 \u05e2\u05dd Codename One +Add\ a\ child=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d9\u05dc\u05d3 +Add\ to\ calendar=\u05d4\u05d5\u05e1\u05e4\u05d4 \u05dc\u05d9\u05d5\u05de\u05df +Added\ to\ your\ calendar=\u05e0\u05d5\u05e1\u05e3 \u05dc\u05d9\u05d5\u05de\u05df \u05e9\u05dc\u05db\u05dd +After\ every\ payment=\u05d0\u05d7\u05e8\u05d9 \u05db\u05dc \u05ea\u05e9\u05dc\u05d5\u05dd +Age\ {0}=\u05d2\u05d9\u05dc {0} +Ages\ {0}\ to\ {1}=\u05d2\u05d9\u05dc\u05d0\u05d9 {0} \u05e2\u05d3 {1} +Allergies,\ asthma,\ an\ injury\ -\ optional=\u05d0\u05dc\u05e8\u05d2\u05d9\u05d5\u05ea, \u05d0\u05e1\u05ea\u05de\u05d4, \u05e4\u05e6\u05d9\u05e2\u05d4 - \u05dc\u05d0 \u05d7\u05d5\u05d1\u05d4 +Amount=\u05e1\u05db\u05d5\u05dd +An\ hour\ before\ every\ class\ you\ booked=\u05e9\u05e2\u05d4 \u05dc\u05e4\u05e0\u05d9 \u05db\u05dc \u05e9\u05d9\u05e2\u05d5\u05e8 \u05e9\u05d4\u05d6\u05de\u05e0\u05ea\u05dd +Anything\ the\ coach\ should\ know=\u05db\u05dc \u05d3\u05d1\u05e8 \u05e9\u05d4\u05de\u05d0\u05de\u05df \u05e6\u05e8\u05d9\u05da \u05dc\u05d3\u05e2\u05ea +Away=\u05d1\u05d4\u05d9\u05e2\u05d3\u05e8\u05d5\u05ea +Away\ periods=\u05ea\u05e7\u05d5\u05e4\u05d5\u05ea \u05d4\u05d9\u05e2\u05d3\u05e8\u05d5\u05ea +Billing\ paused=\u05d4\u05d7\u05d9\u05d5\u05d1 \u05de\u05d5\u05e9\u05d4\u05d4 +Book=\u05d4\u05d6\u05de\u05e0\u05d4 +Booked=\u05d4\u05d5\u05d6\u05de\u05df +Bookings=\u05d4\u05d6\u05de\u05e0\u05d5\u05ea +Calendar\ access\ was\ not\ allowed=\u05d4\u05d2\u05d9\u05e9\u05d4 \u05dc\u05d9\u05d5\u05de\u05df \u05dc\u05d0 \u05d0\u05d5\u05e9\u05e8\u05d4 +Campaigns=\u05e7\u05de\u05e4\u05d9\u05d9\u05e0\u05d9\u05dd +Cancelled\ by\ the\ studio=\u05d1\u05d5\u05d8\u05dc \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 +Challenges,\ offers\ and\ news=\u05d0\u05ea\u05d2\u05e8\u05d9\u05dd, \u05d4\u05e6\u05e2\u05d5\u05ea \u05d5\u05d7\u05d3\u05e9\u05d5\u05ea +Check\ in=\u05e8\u05d9\u05e9\u05d5\u05dd \u05db\u05e0\u05d9\u05e1\u05d4 +Class=\u05e9\u05d9\u05e2\u05d5\u05e8 +Class\ is\ full.\ You\ are\ number\ {0}\ in\ the\ queue=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8 \u05de\u05dc\u05d0. \u05d0\u05ea\u05dd \u05d1\u05de\u05e7\u05d5\u05dd {0} \u05d1\u05e8\u05e9\u05d9\u05de\u05ea \u05d4\u05d4\u05de\u05ea\u05e0\u05d4 +Class\ reminders=\u05ea\u05d6\u05db\u05d5\u05e8\u05d5\u05ea \u05dc\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Class\ templates=\u05ea\u05d1\u05e0\u05d9\u05d5\u05ea \u05e9\u05d9\u05e2\u05d5\u05e8 +Coaching=\u05d0\u05d9\u05de\u05d5\u05df \u05d0\u05d9\u05e9\u05d9 +Coming\ up=\u05d1\u05e7\u05e8\u05d5\u05d1 +Could\ not\ add\ it\ to\ your\ calendar=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05dc\u05d9\u05d5\u05de\u05df \u05e9\u05dc\u05db\u05dd +Day\ pass=\u05db\u05e8\u05d8\u05d9\u05e1 \u05d9\u05d5\u05de\u05d9 +Desk=\u05e7\u05d1\u05dc\u05d4 +Find\ a\ member=\u05d7\u05d9\u05e4\u05d5\u05e9 \u05d7\u05d1\u05e8 +Freeze=\u05d4\u05e7\u05e4\u05d0\u05d4 +Freeze\ the\ plan\ for\ these\ dates=\u05d4\u05e7\u05e4\u05d0\u05ea \u05d4\u05de\u05e0\u05d5\u05d9 \u05d1\u05ea\u05d0\u05e8\u05d9\u05db\u05d9\u05dd \u05d0\u05dc\u05d4 +From=\u05de\u05ea\u05d0\u05e8\u05d9\u05da +Front\ desk=\u05d3\u05dc\u05e4\u05e7 \u05e7\u05d1\u05dc\u05d4 +Frozen=\u05de\u05d5\u05e7\u05e4\u05d0 +Full,\ {0}\ waiting=\u05de\u05dc\u05d0, {0} \u05de\u05de\u05ea\u05d9\u05e0\u05d9\u05dd +Going\ somewhere?\ Tell\ us\ and\ we\ will\ not\ nag\ you.=\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd \u05dc\u05d0\u05e0\u05e9\u05d4\u05d5? \u05e1\u05e4\u05e8\u05d5 \u05dc\u05e0\u05d5 \u05d5\u05dc\u05d0 \u05e0\u05e6\u05d9\u05e7 \u05dc\u05db\u05dd. +Guest=\u05d0\u05d5\u05e8\u05d7 +Hold\ the\ fob\ near\ the\ top=\u05e7\u05e8\u05d1\u05d5 \u05d0\u05ea \u05d4\u05e6\u05de\u05d9\u05d3 \u05dc\u05d7\u05dc\u05e7 \u05d4\u05e2\u05dc\u05d9\u05d5\u05df +Household=\u05de\u05e9\u05e7 \u05d1\u05d9\u05ea +I'll\ be\ away=\u05d0\u05d4\u05d9\u05d4 \u05d1\u05d4\u05d9\u05e2\u05d3\u05e8\u05d5\u05ea +Inbox=\u05ea\u05d9\u05d1\u05ea \u05d4\u05d5\u05d3\u05e2\u05d5\u05ea +Injured=\u05e4\u05e6\u05d5\u05e2 +Injury=\u05e4\u05e6\u05d9\u05e2\u05d4 +Invoices=\u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea +Join\ for\ {0}=\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05d1-{0} +Kids'\ classes\ are\ by\ age,\ so\ the\ timetable\ shows\ them\ the\ classes\ they\ can\ join.\ You\ sign\ their\ waiver\ next.=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9 \u05d4\u05d9\u05dc\u05d3\u05d9\u05dd \u05e0\u05e7\u05d1\u05e2\u05d9\u05dd \u05dc\u05e4\u05d9 \u05d2\u05d9\u05dc, \u05dc\u05db\u05df \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e9\u05e2\u05d5\u05ea \u05de\u05e6\u05d9\u05d2\u05d4 \u05dc\u05d4\u05dd \u05d0\u05ea \u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05d9\u05db\u05d5\u05dc\u05d9\u05dd \u05dc\u05d4\u05e6\u05d8\u05e8\u05e3 \u05d0\u05dc\u05d9\u05d4\u05dd. \u05d1\u05e9\u05dc\u05d1 \u05d4\u05d1\u05d0 \u05ea\u05d7\u05ea\u05de\u05d5 \u05e2\u05dc \u05d4\u05e6\u05d4\u05e8\u05ea \u05d4\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05e9\u05dc\u05d4\u05dd. +Legal=\u05de\u05e9\u05e4\u05d8\u05d9 +Let\ them\ in=\u05dc\u05d0\u05e4\u05e9\u05e8 \u05db\u05e0\u05d9\u05e1\u05d4 +Live\ class=\u05e9\u05d9\u05e2\u05d5\u05e8 \u05d7\u05d9 +Lookup=\u05d7\u05d9\u05e4\u05d5\u05e9 +Lost\ your\ phone\ or\ fob?\ Replace\ the\ pass=\u05d0\u05d9\u05d1\u05d3\u05ea\u05dd \u05d0\u05ea \u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05d0\u05d5 \u05d4\u05e6\u05de\u05d9\u05d3? \u05d4\u05d7\u05dc\u05d9\u05e4\u05d5 \u05d0\u05ea \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Manage\ membership=\u05e0\u05d9\u05d4\u05d5\u05dc \u05d4\u05de\u05e0\u05d5\u05d9 +Member=\u05d7\u05d1\u05e8 +Membership=\u05de\u05e0\u05d5\u05d9 +Membership\ pass=\u05db\u05e8\u05d8\u05d9\u05e1 \u05d7\u05d1\u05e8 +My\ bookings=\u05d4\u05d4\u05d6\u05de\u05e0\u05d5\u05ea \u05e9\u05dc\u05d9 +My\ classes=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05dc\u05d9 +Name\ or\ e-mail=\u05e9\u05dd \u05d0\u05d5 \u05d0\u05d9\u05de\u05d9\u05d9\u05dc +Needed\ for\ reminders\ and\ the\ front\ desk=\u05e0\u05d3\u05e8\u05e9 \u05dc\u05ea\u05d6\u05db\u05d5\u05e8\u05d5\u05ea \u05d5\u05dc\u05d3\u05dc\u05e4\u05e7 \u05d4\u05e7\u05d1\u05dc\u05d4 +News\ from\ the\ studio\ shows\ up\ here.=\u05d7\u05d3\u05e9\u05d5\u05ea \u05de\u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 \u05d9\u05d5\u05e4\u05d9\u05e2\u05d5 \u05db\u05d0\u05df. +No\ classes=\u05d0\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +No\ classes\ attended\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d4\u05e9\u05ea\u05ea\u05e4\u05ea\u05dd \u05d1\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd. +No\ invoices\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea. +No\ plan\ yet.\ Pick\ one\ to\ start\ booking\ classes.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05de\u05e0\u05d5\u05d9. \u05d1\u05d7\u05e8\u05d5 \u05de\u05e0\u05d5\u05d9 \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05dc\u05d4\u05d6\u05de\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd. +No\ time\ away=\u05d0\u05d9\u05df \u05d4\u05d9\u05e2\u05d3\u05e8\u05d5\u05d9\u05d5\u05ea +Nobody\ in\ the\ household\ is\ on\ a\ plan\ yet.=\u05d0\u05e3 \u05d0\u05d7\u05d3 \u05de\u05de\u05e9\u05e7 \u05d4\u05d1\u05d9\u05ea \u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d1\u05de\u05e0\u05d5\u05d9. +Not\ admitted=\u05dc\u05d0 \u05d4\u05ea\u05e7\u05d1\u05dc +Nothing\ booked.=\u05d0\u05d9\u05df \u05d4\u05d6\u05de\u05e0\u05d5\u05ea. +Nothing\ on\ this\ day\ for\ {0}.=\u05d0\u05d9\u05df \u05d3\u05d1\u05e8 \u05d1\u05d9\u05d5\u05dd \u05d6\u05d4 \u05e2\u05d1\u05d5\u05e8 {0}. +Nothing\ on\ this\ day.=\u05d0\u05d9\u05df \u05d3\u05d1\u05e8 \u05d1\u05d9\u05d5\u05dd \u05d6\u05d4. +Nothing\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05db\u05dc\u05d5\u05dd +Nudges=\u05ea\u05d6\u05db\u05d5\u05e8\u05d5\u05ea \u05e2\u05d3\u05d9\u05e0\u05d5\u05ea +Or\ type\ the\ pass=\u05d0\u05d5 \u05d4\u05e7\u05dc\u05d9\u05d3\u05d5 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 +Other=\u05d0\u05d7\u05e8 +Owner=\u05d1\u05e2\u05dc\u05d9\u05dd +Owner\ mode=\u05de\u05e6\u05d1 \u05d1\u05e2\u05dc\u05d9\u05dd +Paid\ by=\u05e9\u05d5\u05dc\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea +Paid.\ Thank\ you=\u05e9\u05d5\u05dc\u05dd. \u05ea\u05d5\u05d3\u05d4 +Pass=\u05db\u05e8\u05d8\u05d9\u05e1 +Pause\ billing=\u05d4\u05e9\u05d4\u05d9\u05d9\u05ea \u05d7\u05d9\u05d5\u05d1 +Pay\ now=\u05ea\u05e9\u05dc\u05d5\u05dd \u05e2\u05db\u05e9\u05d9\u05d5 +Payment\ due=\u05ea\u05e9\u05dc\u05d5\u05dd \u05dc\u05d0 \u05e9\u05d5\u05dc\u05dd +Plan=\u05de\u05e0\u05d5\u05d9 +Plans=\u05de\u05e0\u05d5\u05d9\u05d9\u05dd +Plans\ and\ pricing=\u05de\u05e0\u05d5\u05d9\u05d9\u05dd \u05d5\u05de\u05d7\u05d9\u05e8\u05d9\u05dd +Pulse=Pulse +Pulse\ is\ a\ sample.\ No\ real\ classes\ are\ booked,\ no\ studio\ is\ opened\ to\ you\ and\ no\ money\ changes\ hands.=Pulse \u05d4\u05d9\u05d0 \u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d9\u05ea \u05d3\u05d5\u05d2\u05de\u05d4. \u05dc\u05d0 \u05de\u05d5\u05d6\u05de\u05e0\u05d9\u05dd \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d0\u05de\u05d9\u05ea\u05d9\u05d9\u05dd, \u05e9\u05d5\u05dd \u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 \u05dc\u05d0 \u05e0\u05e4\u05ea\u05d7 \u05d1\u05e4\u05e0\u05d9\u05db\u05dd \u05d5\u05d0\u05d9\u05df \u05d4\u05e2\u05d1\u05e8\u05ea \u05db\u05e1\u05e3. +Queue\ \#{0}=\u05d4\u05de\u05ea\u05e0\u05d4 #{0} +Roster=\u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05e9\u05ea\u05ea\u05e4\u05d9\u05dd +Scan\ pass=\u05e1\u05e8\u05d9\u05e7\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 +Schedule=\u05dc\u05d5\u05d7 \u05d6\u05de\u05e0\u05d9\u05dd +See\ plans=\u05e6\u05e4\u05d9\u05d9\u05d4 \u05d1\u05de\u05e0\u05d5\u05d9\u05d9\u05dd +Shifts=\u05de\u05e9\u05de\u05e8\u05d5\u05ea +Sign\ waiver=\u05d7\u05ea\u05d9\u05de\u05d4 \u05e2\u05dc \u05d4\u05e6\u05d4\u05e8\u05ea \u05d1\u05e8\u05d9\u05d0\u05d5\u05ea +Some\ classes\ are\ for\ an\ age\ band,\ so\ the\ timetable\ needs\ to\ know.\ Children\ are\ added\ later,\ from\ Household.=\u05d7\u05dc\u05e7 \u05de\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05de\u05d9\u05d5\u05e2\u05d3\u05d9\u05dd \u05dc\u05d8\u05d5\u05d5\u05d7 \u05d2\u05d9\u05dc\u05d0\u05d9\u05dd, \u05d5\u05dc\u05db\u05df \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e9\u05e2\u05d5\u05ea \u05e6\u05e8\u05d9\u05db\u05d4 \u05dc\u05d3\u05e2\u05ea. \u05d9\u05dc\u05d3\u05d9\u05dd \u05de\u05d5\u05e1\u05d9\u05e4\u05d9\u05dd \u05d1\u05d4\u05de\u05e9\u05da, \u05de\u05de\u05e9\u05e7 \u05d4\u05d1\u05d9\u05ea. +Staff=\u05e6\u05d5\u05d5\u05ea +Starting=\u05de\u05ea\u05d7\u05d9\u05dc +Studio=\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 +Studio\ owner=\u05d1\u05e2\u05dc \u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 +Tap\ fob=\u05d4\u05e6\u05de\u05d3 \u05e6\u05de\u05d9\u05d3 +The\ fob\ could\ not\ be\ read=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05e6\u05de\u05d9\u05d3 +The\ scan\ failed.\ Try\ again=\u05d4\u05e1\u05e8\u05d9\u05e7\u05d4 \u05e0\u05db\u05e9\u05dc\u05d4. \u05e0\u05e1\u05d5 \u05e9\u05d5\u05d1 +Their\ first\ and\ last\ name=\u05e9\u05dd \u05e4\u05e8\u05d8\u05d9 \u05d5\u05e9\u05dd \u05de\u05e9\u05e4\u05d7\u05d4 +This\ device\ cannot\ read\ NFC=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05e7\u05e8\u05d5\u05d0 NFC +This\ device\ has\ no\ calendar\ to\ add\ to=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d0\u05d9\u05df \u05d9\u05d5\u05de\u05df \u05dc\u05d4\u05d5\u05e1\u05e4\u05d4 \u05d0\u05dc\u05d9\u05d5 +This\ device\ has\ no\ scanner.\ Type\ the\ pass\ instead=\u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d0\u05d9\u05df \u05e1\u05d5\u05e8\u05e7. \u05d4\u05e7\u05dc\u05d9\u05d3\u05d5 \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05de\u05e7\u05d5\u05dd +Time\ away=\u05d4\u05d9\u05e2\u05d3\u05e8\u05d5\u05ea +Timetable=\u05de\u05e2\u05e8\u05db\u05ea \u05e9\u05e2\u05d5\u05ea +Trainer=\u05de\u05d0\u05de\u05df +Trainer\ mode=\u05de\u05e6\u05d1 \u05de\u05d0\u05de\u05df +Travel=\u05e0\u05e1\u05d9\u05e2\u05d4 +Travelling=\u05d1\u05e0\u05e1\u05d9\u05e2\u05d4 +Travelling\ or\ injured?\ No\ nudges,\ and\ billing\ can\ pause=\u05d1\u05e0\u05e1\u05d9\u05e2\u05d4 \u05d0\u05d5 \u05e4\u05e6\u05d5\u05e2\u05d9\u05dd? \u05d1\u05dc\u05d9 \u05ea\u05d6\u05db\u05d5\u05e8\u05d5\u05ea, \u05d5\u05d4\u05d7\u05d9\u05d5\u05d1 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05d5\u05e9\u05d4\u05d4 +Trial=\u05e0\u05d9\u05e1\u05d9\u05d5\u05df +Unfreeze=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d4\u05e7\u05e4\u05d0\u05d4 +Unreachable=\u05dc\u05d0 \u05d6\u05de\u05d9\u05df +Until=\u05e2\u05d3 \u05ea\u05d0\u05e8\u05d9\u05da +Visit=\u05d1\u05d9\u05e7\u05d5\u05e8 +Visits\ this\ month=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd \u05d4\u05d7\u05d5\u05d3\u05e9 +Waiting\ for\ the\ next\ member.=\u05de\u05de\u05ea\u05d9\u05df \u05dc\u05d7\u05d1\u05e8 \u05d4\u05d1\u05d0. +Welcome\ in=\u05d1\u05e8\u05d5\u05db\u05d9\u05dd \u05d4\u05d1\u05d0\u05d9\u05dd +Welcome\ to\ Pulse=\u05d1\u05e8\u05d5\u05db\u05d9\u05dd \u05d4\u05d1\u05d0\u05d9\u05dd \u05dc-Pulse +What\ your\ coaches\ will\ call\ you=\u05d0\u05d9\u05da \u05d4\u05de\u05d0\u05de\u05e0\u05d9\u05dd \u05d9\u05e7\u05e8\u05d0\u05d5 \u05dc\u05db\u05dd +Why=\u05e1\u05d9\u05d1\u05d4 +With\ {0}=\u05e2\u05dd {0} +Your\ bookings,\ visits,\ household\ and\ settings\ are\ kept\ with\ your\ account\ on\ the\ server\ you\ signed\ in\ to.\ Your\ membership\ pass\ is\ a\ code\ this\ phone\ works\ out\ from\ a\ secret\ that\ server\ gave\ it,\ and\ nothing\ else\ about\ you\ is\ in\ it.=\u05d4\u05d4\u05d6\u05de\u05e0\u05d5\u05ea, \u05d4\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd, \u05de\u05e9\u05e7 \u05d4\u05d1\u05d9\u05ea \u05d5\u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05e9\u05dc\u05db\u05dd \u05e0\u05e9\u05de\u05e8\u05d9\u05dd \u05e2\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e9\u05e8\u05ea \u05e9\u05d0\u05dc\u05d9\u05d5 \u05d4\u05ea\u05d7\u05d1\u05e8\u05ea\u05dd. \u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d7\u05d1\u05e8 \u05d4\u05d5\u05d0 \u05e7\u05d5\u05d3 \u05e9\u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05de\u05d7\u05e9\u05d1 \u05de\u05e1\u05d5\u05d3 \u05e9\u05d4\u05e9\u05e8\u05ea \u05de\u05e1\u05e8 \u05dc\u05d5, \u05d5\u05d0\u05d9\u05df \u05d1\u05d5 \u05e9\u05d5\u05dd \u05de\u05d9\u05d3\u05e2 \u05d0\u05d7\u05e8 \u05e2\u05dc\u05d9\u05db\u05dd. +Your\ name=\u05d4\u05e9\u05dd \u05e9\u05dc\u05db\u05dd +Your\ old\ pass\ and\ fob\ no\ longer\ open\ the\ door=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05d5\u05d4\u05e6\u05de\u05d9\u05d3 \u05d4\u05d9\u05e9\u05e0\u05d9\u05dd \u05e9\u05dc\u05db\u05dd \u05db\u05d1\u05e8 \u05dc\u05d0 \u05e4\u05d5\u05ea\u05d7\u05d9\u05dd \u05d0\u05ea \u05d4\u05d3\u05dc\u05ea +Your\ plan\ ends\ at\ the\ end\ of\ this\ period=\u05d4\u05de\u05e0\u05d5\u05d9 \u05e9\u05dc\u05db\u05dd \u05de\u05e1\u05ea\u05d9\u05d9\u05dd \u05d1\u05ea\u05d5\u05dd \u05d4\u05ea\u05e7\u05d5\u05e4\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea +Your\ plan,\ freezes\ and\ invoices=\u05d4\u05de\u05e0\u05d5\u05d9, \u05d4\u05d4\u05e7\u05e4\u05d0\u05d5\u05ea \u05d5\u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea \u05e9\u05dc\u05db\u05dd +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Classes\ you\ attended\ stay\ in\ the\ studio's\ records,\ under\ the\ email\ address\ you\ signed\ in\ with.=\u05e4\u05e8\u05d8\u05d9 \u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea, \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc, \u05d4\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd \u05d4\u05e9\u05de\u05d5\u05e8\u05d9\u05dd \u05d5\u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05e9\u05dc\u05db\u05dd \u05d9\u05d9\u05de\u05d7\u05e7\u05d5 \u05dc\u05e6\u05de\u05d9\u05ea\u05d5\u05ea. \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05d4\u05e9\u05ea\u05ea\u05e4\u05ea\u05dd \u05d1\u05d4\u05dd \u05d9\u05d9\u05e9\u05d0\u05e8\u05d5 \u05d1\u05e8\u05d9\u05e9\u05d5\u05de\u05d9 \u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5, \u05ea\u05d7\u05ea \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05d9\u05de\u05d9\u05d9\u05dc \u05e9\u05d1\u05d4 \u05d4\u05ea\u05d7\u05d1\u05e8\u05ea\u05dd. +ends\ {0}=\u05de\u05e1\u05ea\u05d9\u05d9\u05dd \u05d1-{0} +for\ the\ follow-up=\u05dc\u05d4\u05de\u05e9\u05da \u05d8\u05d9\u05e4\u05d5\u05dc +frozen\ until\ {0}=\u05de\u05d5\u05e7\u05e4\u05d0 \u05e2\u05d3 {0} +renews\ {0}=\u05de\u05ea\u05d7\u05d3\u05e9 \u05d1-{0} +{0}\ classes\ left=\u05e0\u05e9\u05d0\u05e8\u05d5 {0} \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +{0}\ is\ in={0} \u05e0\u05db\u05e0\u05e1/\u05d4 +{0}\ of\ {1}\ places\ left=\u05e0\u05e9\u05d0\u05e8\u05d5 {0} \u05de\u05ea\u05d5\u05da {1} \u05de\u05e7\u05d5\u05de\u05d5\u05ea +{0}\ starts\ in\ an\ hour.\ See\ you\ there,\ {1}={0} \u05de\u05ea\u05d7\u05d9\u05dc \u05d1\u05e2\u05d5\u05d3 \u05e9\u05e2\u05d4. \u05e0\u05ea\u05e8\u05d0\u05d4 \u05e9\u05dd, {1} +{0}\ weeks\ ago=\u05dc\u05e4\u05e0\u05d9 {0} \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +1\ friend=\u05d7\u05d1\u05e8/\u05d4 \u05d0\u05d7\u05d3/\u05ea +{0}\ friends={0} \u05d7\u05d1\u05e8\u05d9\u05dd +Wear\ a\ watch\ or\ a\ strap\ to\ earn\ bonus\ points.=\u05d0\u05d9\u05de\u05d5\u05df \u05e2\u05dd \u05e9\u05e2\u05d5\u05df \u05d0\u05d5 \u05e8\u05e6\u05d5\u05e2\u05d4 \u05de\u05d6\u05db\u05d4 \u05d1\u05e0\u05e7\u05d5\u05d3\u05d5\u05ea \u05d1\u05d5\u05e0\u05d5\u05e1. +Check-ins=\u05db\u05e0\u05d9\u05e1\u05d5\u05ea +Walk-ins=\u05de\u05d6\u05d3\u05de\u05e0\u05d9\u05dd +Import\ from\ Health=\u05d9\u05d9\u05d1\u05d5\u05d0 \u05de\u05d4\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea +Joined=\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5 +Left=\u05e2\u05d6\u05d1\u05d5 +Key\ fob=\u05ea\u05d2 \u05db\u05e0\u05d9\u05e1\u05d4 +Pass\ number=\u05de\u05e1\u05e4\u05e8 \u05db\u05e8\u05d8\u05d9\u05e1 +Past\ due=\u05d1\u05e4\u05d9\u05d2\u05d5\u05e8 +Monthly\ recurring=\u05d7\u05d5\u05d6\u05e8 \u05d7\u05d5\u05d3\u05e9\u05d9 +Show\ this\ at\ the\ front\ desk.\ It\ refreshes\ every\ {0}\ seconds.=\u05d4\u05e6\u05d2 \u05d1\u05e7\u05d1\u05dc\u05d4. \u05d4\u05e7\u05d5\u05d3 \u05de\u05ea\u05d7\u05d3\u05e9 \u05db\u05dc {0} \u05e9\u05e0\u05d9\u05d5\u05ea. +Takings=\u05d4\u05db\u05e0\u05e1\u05d5\u05ea +Visits\ /\ wk=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd / \u05e9\u05d1\u05d5\u05e2 +Visits\ each\ week=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd \u05d1\u05db\u05dc \u05e9\u05d1\u05d5\u05e2 +{0}\ booked={0} \u05d4\u05d6\u05de\u05e0\u05d5\u05ea +{0}\ bpm\ avg=\u05de\u05de\u05d5\u05e6\u05e2 {0} \u05d3\u05d5\u05e4\u05e7\u05d9\u05dd +{0}\ more\ to\ level\ {1}=\u05e2\u05d5\u05d3 {0} \u05dc\u05e8\u05de\u05d4 {1} +Jan=\u05d9\u05e0\u05d5\u05f3 +Feb=\u05e4\u05d1\u05e8\u05f3 +Mar=\u05de\u05e8\u05e5 +Apr=\u05d0\u05e4\u05e8\u05f3 +May=\u05de\u05d0\u05d9 +Jun=\u05d9\u05d5\u05e0\u05d9 +Jul=\u05d9\u05d5\u05dc\u05d9 +Aug=\u05d0\u05d5\u05d2\u05f3 +Sep=\u05e1\u05e4\u05d8\u05f3 +Oct=\u05d0\u05d5\u05e7\u05f3 +Nov=\u05e0\u05d5\u05d1\u05f3 +Dec=\u05d3\u05e6\u05de\u05f3 + +# The owner's insights, members and billing, and the trainer's month. +Insights=\u05ea\u05d5\u05d1\u05e0\u05d5\u05ea +Billing=\u05d7\u05d9\u05d5\u05d1\u05d9\u05dd +Busy\ hours,\ growth,\ classes\ and\ coaches=\u05e9\u05e2\u05d5\u05ea \u05e2\u05d5\u05de\u05e1, \u05e6\u05de\u05d9\u05d7\u05d4, \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d5\u05de\u05d0\u05de\u05e0\u05d9\u05dd +{0}\ at\ risk={0} \u05d1\u05e1\u05d9\u05db\u05d5\u05df +{0}\ invoices\ past\ due={0} \u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05d0\u05d9\u05d7\u05d5\u05e8 +Still\ with\ us\ after\ 90\ days=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05ea\u05e0\u05d5 \u05d0\u05d7\u05e8\u05d9 90 \u05d9\u05d5\u05dd +Visits\ a\ member\ a\ week=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd \u05dc\u05d7\u05d1\u05e8 \u05d1\u05e9\u05d1\u05d5\u05e2 +No-shows=\u05dc\u05d0 \u05d4\u05d2\u05d9\u05e2\u05d5 +Revenue\ a\ member=\u05d4\u05db\u05e0\u05e1\u05d4 \u05dc\u05d7\u05d1\u05e8 +Busy\ hours=\u05e9\u05e2\u05d5\u05ea \u05e2\u05d5\u05de\u05e1 +Check-ins\ over\ the\ last\ eight\ weeks.=\u05db\u05e0\u05d9\u05e1\u05d5\u05ea \u05d1\u05e9\u05de\u05d5\u05e0\u05ea \u05d4\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd. +Classes=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd +Plan\ mix=\u05ea\u05de\u05d4\u05d9\u05dc \u05de\u05e1\u05dc\u05d5\u05dc\u05d9\u05dd +Coaches=\u05de\u05d0\u05de\u05e0\u05d9\u05dd +No\ members\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d7\u05d1\u05e8\u05d9\u05dd. +No\ classes\ in\ the\ last\ four\ weeks.=\u05dc\u05d0 \u05d4\u05d9\u05d5 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d1\u05d0\u05e8\u05d1\u05e2\u05ea \u05d4\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd. +{0}%\ full,\ {1}%\ no-show={0}% \u05de\u05dc\u05d0, {1}% \u05dc\u05d0 \u05d4\u05d2\u05d9\u05e2\u05d5 +{0}\:\ {1}%\ full={0}: {1}% \u05de\u05dc\u05d0 +No\ plans\ sold\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e0\u05de\u05db\u05e8\u05d5 \u05de\u05e1\u05dc\u05d5\u05dc\u05d9\u05dd. +{0}\:\ {1}\ members={0}: {1} \u05d7\u05d1\u05e8\u05d9\u05dd +{0}\ members={0} \u05d7\u05d1\u05e8\u05d9\u05dd +{0}\ a\ month={0} \u05d1\u05d7\u05d5\u05d3\u05e9 +No\ classes\ taught\ in\ the\ last\ four\ weeks.=\u05dc\u05d0 \u05d4\u05d5\u05e2\u05d1\u05e8\u05d5 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d1\u05d0\u05e8\u05d1\u05e2\u05ea \u05d4\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd. +{0}\ classes,\ {1}%\ full,\ {2}%\ show\ up={0} \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd, {1}% \u05de\u05dc\u05d0, {2}% \u05d4\u05d2\u05d9\u05e2\u05d5 +Mon=\u05d1\u05f3 +Tue=\u05d2\u05f3 +Wed=\u05d3\u05f3 +Thu=\u05d4\u05f3 +Fri=\u05d5\u05f3 +Sat=\u05e9\u05f3 +Sun=\u05d0\u05f3 +No\ visits\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd +Busiest\:\ {0}\ at\ {1}\:00=\u05d4\u05e2\u05de\u05d5\u05e1 \u05d1\u05d9\u05d5\u05ea\u05e8: {0} \u05d1-{1}:00 +No\ members\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05d7\u05d1\u05e8\u05d9\u05dd +{0}\ active\ members,\ {1}\ twelve\ weeks\ ago={0} \u05d7\u05d1\u05e8\u05d9\u05dd \u05e4\u05e2\u05d9\u05dc\u05d9\u05dd, {1} \u05dc\u05e4\u05e0\u05d9 \u05e9\u05e0\u05d9\u05dd-\u05e2\u05e9\u05e8 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +Search\ by\ name=\u05d7\u05d9\u05e4\u05d5\u05e9 \u05dc\u05e4\u05d9 \u05e9\u05dd +All=\u05d4\u05db\u05d5\u05dc +{0}\ in\ 30\ days={0} \u05d1-30 \u05d9\u05d5\u05dd +Nobody\ by\ that\ name=\u05d0\u05d9\u05df \u05d0\u05e3 \u05d0\u05d7\u05d3 \u05d1\u05e9\u05dd \u05d4\u05d6\u05d4 +No\ members\ here=\u05d0\u05d9\u05df \u05db\u05d0\u05df \u05d7\u05d1\u05e8\u05d9\u05dd +Try\ another\ filter.=\u05e0\u05e1\u05d4 \u05de\u05e1\u05e0\u05df \u05d0\u05d7\u05e8. +Never\ in=\u05dc\u05d0 \u05d4\u05d2\u05d9\u05e2 \u05de\u05e2\u05d5\u05dc\u05dd +In\ today=\u05d4\u05d2\u05d9\u05e2 \u05d4\u05d9\u05d5\u05dd +Yesterday=\u05d0\u05ea\u05de\u05d5\u05dc +{0}\ days\ ago=\u05dc\u05e4\u05e0\u05d9 {0} \u05d9\u05de\u05d9\u05dd +Joined\ {0}=\u05d4\u05e6\u05d8\u05e8\u05e3 \u05d1-{0} +Visits\ in\ 30\ days=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd \u05d1-30 \u05d9\u05d5\u05dd +Week\ streak=\u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d1\u05e8\u05e6\u05e3 +Lifetime\ value=\u05e2\u05e8\u05da \u05de\u05e6\u05d8\u05d1\u05e8 +Visits\ a\ week=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd \u05d1\u05e9\u05d1\u05d5\u05e2 +11\ weeks\ ago=\u05dc\u05e4\u05e0\u05d9 11 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +Favourite\ classes=\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d0\u05d4\u05d5\u05d1\u05d9\u05dd +{0}\ visits={0} \u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd +No\ classes\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05d0\u05d9\u05df \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd. +No\ invoices=\u05d0\u05d9\u05df \u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05d5\u05ea +Failed=\u05e0\u05db\u05e9\u05dc +Open=\u05e4\u05ea\u05d5\u05d7 +Void=\u05d1\u05d8\u05dc +Collected\ in\ 30\ days=\u05e0\u05d2\u05d1\u05d4 \u05d1-30 \u05d9\u05d5\u05dd +Outstanding=\u05d9\u05ea\u05e8\u05d4 \u05dc\u05d2\u05d1\u05d9\u05d9\u05d4 +Attempt\ {0},\ next\ {1}=\u05e0\u05d9\u05e1\u05d9\u05d5\u05df {0}, \u05d4\u05d1\u05d0 {1} +Attempt\ {0},\ no\ more\ retries=\u05e0\u05d9\u05e1\u05d9\u05d5\u05df {0}, \u05d0\u05d9\u05df \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd +Void\ invoice=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05ea +{0}\ for\ {1}\ will\ not\ be\ charged.\ A\ membership\ it\ held\ up\ runs\ again.={0} \u05e2\u05d1\u05d5\u05e8 {1} \u05dc\u05d0 \u05d9\u05d7\u05d5\u05d9\u05d1. \u05de\u05e0\u05d5\u05d9 \u05e9\u05e0\u05d7\u05e1\u05dd \u05d1\u05d2\u05dc\u05dc\u05d5 \u05d9\u05d7\u05d6\u05d5\u05e8 \u05dc\u05e4\u05e2\u05d5\u05dc. +Invoice\ voided=\u05d4\u05d7\u05e9\u05d1\u05d5\u05e0\u05d9\u05ea \u05d1\u05d5\u05d8\u05dc\u05d4 +Your\ month=\u05d4\u05d7\u05d5\u05d3\u05e9 \u05e9\u05dc\u05da +Places\ filled=\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05e9\u05e0\u05ea\u05e4\u05e1\u05d5 +Showed\ up=\u05d4\u05d2\u05d9\u05e2\u05d5 +Regulars=\u05e7\u05d1\u05d5\u05e2\u05d9\u05dd +Kudos=\u05de\u05d7\u05de\u05d0\u05d5\u05ea +Check-ins\ a\ week=\u05e6'\u05e7-\u05d0\u05d9\u05e0\u05d9\u05dd \u05d1\u05e9\u05d1\u05d5\u05e2 +7\ weeks\ ago=\u05dc\u05e4\u05e0\u05d9 7 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea +Top\ classes=\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d4\u05de\u05d5\u05d1\u05d9\u05dc\u05d9\u05dd +{0}\ more\ than\ twelve\ weeks\ ago=עוד {0} מלפני שנים עשר שבועות +{0}\ fewer\ than\ twelve\ weeks\ ago={0} פחות מלפני שנים עשר שבועות +Paid\ per\ pack=משולם לפי כרטיסייה +Full=\u05de\u05dc\u05d0 +Your\ game=\u05d4\u05de\u05e9\u05d7\u05e7 \u05e9\u05dc\u05da +Leaderboard=\u05d8\u05d1\u05dc\u05ea \u05d4\u05de\u05d5\u05d1\u05d9\u05dc\u05d9\u05dd +You=\u05d0\u05ea/\u05d4 +Rank\ {0}\ of\ {1}=\u05de\u05e7\u05d5\u05dd {0} \u05de\u05ea\u05d5\u05da {1} +10\ points\ a\ check-in,\ 5\ a\ kudos\ and\ 25\ a\ full\ class.=10 \u05e0\u05e7\u05d5\u05d3\u05d5\u05ea \u05dc\u05db\u05dc \u05e6'\u05e7-\u05d0\u05d9\u05df, 5 \u05dc\u05db\u05dc \u05de\u05d7\u05de\u05d0\u05d4 \u05d5-25 \u05dc\u05e9\u05d9\u05e2\u05d5\u05e8 \u05de\u05dc\u05d0. +{0}\ check-ins={0} \u05e6'\u05e7-\u05d0\u05d9\u05e0\u05d9\u05dd +Full\ house=\u05d1\u05d9\u05ea \u05de\u05dc\u05d0 +Packed\ month=\u05d7\u05d5\u05d3\u05e9 \u05de\u05dc\u05d0 +Ten\ regulars=\u05e2\u05e9\u05e8\u05d4 \u05e7\u05d1\u05d5\u05e2\u05d9\u05dd +Crowd\ favourite=\u05d4\u05d0\u05d4\u05d5\u05d1 \u05e2\u05dc \u05d4\u05e7\u05d4\u05dc +No\ empty\ mats=\u05d0\u05d9\u05df \u05de\u05d6\u05e8\u05e0\u05d9\u05dd \u05e8\u05d9\u05e7\u05d9\u05dd +Eight-week\ run=\u05e9\u05de\u05d5\u05e0\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d1\u05e8\u05e6\u05e3 +Hundred\ club=\u05de\u05d5\u05e2\u05d3\u05d5\u05df \u05d4\u05de\u05d0\u05d4 +Top\ coach=\u05d4\u05de\u05d0\u05de\u05df \u05d4\u05de\u05d5\u05d1\u05d9\u05dc +Fill\ every\ place\ in\ a\ class.=\u05de\u05dc\u05d0/\u05d9 \u05d0\u05ea \u05db\u05dc \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d1\u05e9\u05d9\u05e2\u05d5\u05e8. +Fill\ five\ classes\ in\ four\ weeks.=\u05de\u05dc\u05d0/\u05d9 \u05d7\u05de\u05d9\u05e9\u05d4 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05d1\u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea. +Have\ ten\ members\ come\ to\ three\ or\ more\ of\ your\ classes\ in\ four\ weeks.=\u05e2\u05e9\u05e8\u05d4 \u05de\u05e0\u05d5\u05d9\u05d9\u05dd \u05de\u05d2\u05d9\u05e2\u05d9\u05dd \u05dc\u05e9\u05dc\u05d5\u05e9\u05d4 \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05dc\u05da \u05d0\u05d5 \u05d9\u05d5\u05ea\u05e8 \u05d1\u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea. +Collect\ 25\ kudos\ on\ workouts\ from\ your\ classes\ in\ four\ weeks.=\u05d0\u05e1\u05d5\u05e3/\u05d9 25 \u05de\u05d7\u05de\u05d0\u05d5\u05ea \u05e2\u05dc \u05d0\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05de\u05d4\u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd \u05e9\u05dc\u05da \u05d1\u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea. +Have\ nine\ in\ ten\ booked\ members\ show\ up,\ over\ twenty\ or\ more\ check-ins.=\u05ea\u05e9\u05e2\u05d4 \u05de\u05db\u05dc \u05e2\u05e9\u05e8\u05d4 \u05e0\u05e8\u05e9\u05de\u05d9\u05dd \u05de\u05d2\u05d9\u05e2\u05d9\u05dd, \u05d1\u05e2\u05e9\u05e8\u05d9\u05dd \u05e6'\u05e7-\u05d0\u05d9\u05e0\u05d9\u05dd \u05d0\u05d5 \u05d9\u05d5\u05ea\u05e8. +Teach\ every\ week\ for\ eight\ weeks\ running.=\u05dc\u05d9\u05de\u05d5\u05d3 \u05d1\u05db\u05dc \u05e9\u05d1\u05d5\u05e2 \u05d1\u05de\u05e9\u05da \u05e9\u05de\u05d5\u05e0\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea \u05d1\u05e8\u05e6\u05e3. +Coach\ a\ hundred\ check-ins\ in\ eight\ weeks.=\u05d0\u05d9\u05de\u05d5\u05df \u05e9\u05dc \u05de\u05d0\u05d4 \u05e6'\u05e7-\u05d0\u05d9\u05e0\u05d9\u05dd \u05d1\u05e9\u05de\u05d5\u05e0\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea. +Coach\ more\ people\ than\ anyone\ in\ the\ studio\ over\ four\ weeks.=\u05d0\u05d9\u05de\u05d5\u05df \u05e9\u05dc \u05d9\u05d5\u05ea\u05e8 \u05d0\u05e0\u05e9\u05d9\u05dd \u05de\u05db\u05dc \u05d0\u05d7\u05d3 \u05d0\u05d7\u05e8 \u05d1\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5 \u05d1\u05d0\u05e8\u05d1\u05e2\u05d4 \u05e9\u05d1\u05d5\u05e2\u05d5\u05ea. +Accounts=\u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Search\ by\ name\ or\ e-mail=\u05d7\u05d9\u05e4\u05d5\u05e9 \u05dc\u05e4\u05d9 \u05e9\u05dd \u05d0\u05d5 \u05d3\u05d5\u05d0"\u05dc +Flagged=\u05de\u05e1\u05d5\u05de\u05df +Blocked=\u05d7\u05e1\u05d5\u05dd +No\ accounts\ here=\u05d0\u05d9\u05df \u05db\u05d0\u05df \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +not\ verified=\u05dc\u05d0 \u05de\u05d0\u05d5\u05de\u05ea +Roles=\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd +Visits=\u05d1\u05d9\u05e7\u05d5\u05e8\u05d9\u05dd +Spent=\u05d4\u05d5\u05e6\u05d0\u05d4 +Teaches\ classes,\ sees\ rosters\ and\ the\ live\ view.=\u05de\u05dc\u05de\u05d3/\u05ea \u05e9\u05d9\u05e2\u05d5\u05e8\u05d9\u05dd, \u05e8\u05d5\u05d0\u05d4 \u05e8\u05e9\u05d9\u05de\u05d5\u05ea \u05de\u05e9\u05ea\u05ea\u05e4\u05d9\u05dd \u05d5\u05ea\u05e6\u05d5\u05d2\u05d4 \u05d7\u05d9\u05d4. +Checks\ people\ in\ at\ the\ door\ and\ takes\ walk-ins.=\u05e8\u05d5\u05e9\u05dd/\u05ea \u05e0\u05db\u05e0\u05e1\u05d9\u05dd \u05d1\u05d3\u05dc\u05ea \u05d5\u05de\u05e7\u05d1\u05dc/\u05ea \u05de\u05d6\u05d3\u05de\u05e0\u05d9\u05dd. +Runs\ the\ studio\:\ everything\ on\ these\ screens.=\u05de\u05e0\u05d4\u05dc/\u05ea \u05d0\u05ea \u05d4\u05e1\u05d8\u05d5\u05d3\u05d9\u05d5: \u05db\u05dc \u05de\u05d4 \u05e9\u05d1\u05de\u05e1\u05db\u05d9\u05dd \u05d4\u05d0\u05dc\u05d4. +No\ waiver=\u05dc\u05dc\u05d0 \u05d4\u05e6\u05d4\u05e8\u05d4 +Nothing\ done\ to\ this\ account\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e0\u05e2\u05e9\u05d4 \u05d3\u05d1\u05e8 \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4. +Clear\ the\ flag=\u05d4\u05e1\u05e8\u05ea \u05d4\u05e1\u05d9\u05de\u05d5\u05df +Flag\ for\ a\ closer\ look=\u05e1\u05d9\u05de\u05d5\u05df \u05dc\u05d1\u05d3\u05d9\u05e7\u05d4 +Flag\ account=\u05e1\u05d9\u05de\u05d5\u05df \u05d7\u05e9\u05d1\u05d5\u05df +Other\ owners\ see\ the\ flag\ and\ why.\ The\ account\ keeps\ working.=\u05d1\u05e2\u05dc\u05d9\u05dd \u05d0\u05d7\u05e8\u05d9\u05dd \u05e8\u05d5\u05d0\u05d9\u05dd \u05d0\u05ea \u05d4\u05e1\u05d9\u05de\u05d5\u05df \u05d5\u05d0\u05ea \u05d4\u05e1\u05d9\u05d1\u05d4. \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05de\u05de\u05e9\u05d9\u05da \u05dc\u05e4\u05e2\u05d5\u05dc. +Flag=\u05e1\u05d9\u05de\u05d5\u05df +Lift\ the\ block=\u05d4\u05e1\u05e8\u05ea \u05d4\u05d7\u05e1\u05d9\u05de\u05d4 +Block\ account=\u05d7\u05e1\u05d9\u05de\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df +They\ are\ signed\ out\ at\ once\ and\ cannot\ sign\ in,\ book\ or\ check\ in\ until\ the\ block\ is\ lifted.=\u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05de\u05e0\u05d5\u05ea\u05e7 \u05de\u05d9\u05d3 \u05d5\u05dc\u05d0 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8, \u05dc\u05d4\u05d6\u05de\u05d9\u05df \u05d0\u05d5 \u05dc\u05e2\u05e9\u05d5\u05ea \u05e6'\u05e7-\u05d0\u05d9\u05df \u05e2\u05d3 \u05dc\u05d4\u05e1\u05e8\u05ea \u05d4\u05d7\u05e1\u05d9\u05de\u05d4. +Block=\u05d7\u05e1\u05d9\u05de\u05d4 +Block\ lifted=\u05d4\u05d7\u05e1\u05d9\u05de\u05d4 \u05d4\u05d5\u05e1\u05e8\u05d4 +Flag\ cleared=\u05d4\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d5\u05e1\u05e8 +Roles\ changed=\u05d4\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd \u05e9\u05d5\u05e0\u05d5 +Approved=\u05d0\u05d5\u05e9\u05e8 +Rejected=\u05e0\u05d3\u05d7\u05d4 +Withdraw=\u05d1\u05d9\u05d8\u05d5\u05dc +Account\ holder=\u05d1\u05e2\u05dc/\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df +Child=\u05d9\u05dc\u05d3/\u05d4 diff --git a/scripts/pulse/common/src/main/resources/surfaces.json b/scripts/pulse/common/src/main/resources/surfaces.json new file mode 100644 index 00000000000..13b1c3a51e4 --- /dev/null +++ b/scripts/pulse/common/src/main/resources/surfaces.json @@ -0,0 +1,3 @@ +{ + "liveActivities": true +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/Actor.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/Actor.java new file mode 100644 index 00000000000..c5d3e6db581 --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/Actor.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.ui.CN; +import com.codenameone.examples.pulse.net.Session; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// The other person in a test. The app on screen is one user; a delivery needs +/// two, so the second one acts through the API directly, with their own token. +/// +/// Their requests opt out of the app's authorizer, which would otherwise put +/// the on-screen user's token on anything sent to `/api`. +final class Actor { + private final String token; + int status; + + /// One sign-in for each account for the whole run. The server limits how + /// often an address may sign in, as it should, and a suite that signed in + /// again for every step would be turned away by it. + private static final Map SIGNED_IN = new HashMap(); + + private Actor(String token) { + this.token = token; + } + + /// Signs `email` in the way the app does, and keeps the token to itself. + static Actor signIn(String email) { + Actor known = SIGNED_IN.get(email); + if (known != null) { + return new Actor(known.token); + } + Actor fresh = authenticate(email); + SIGNED_IN.put(email, fresh); + return fresh; + } + + /// Whether `email` can sign in at this moment, asked of the server and + /// kept by nobody: this is how a test sees a blocked account refused. + static boolean canSignIn(String email) { + try { + authenticate(email); + return true; + } catch (RuntimeException refused) { + return false; + } + } + + private static Actor authenticate(String email) { + final String[] token = new String[1]; + final String[] problem = new String[1]; + CN.callSerially(() -> Session.authenticate(email, E2e.PASSWORD, + tokens -> token[0] = tokens.getAccessToken(), + (status, message) -> problem[0] = status + " " + message)); + long deadline = System.currentTimeMillis() + 30000; + while (token[0] == null && problem[0] == null && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(50); + } catch (InterruptedException stop) { + break; + } + } + if (token[0] == null) { + throw new RuntimeException("Could not sign " + email + " in: " + problem[0]); + } + return new Actor(token[0]); + } + + Map get(String path) { + return send("GET", path, null); + } + + Map put(String path, String json) { + return send("PUT", path, json); + } + + Map post(String path, String json) { + return send("POST", path, json == null ? "{}" : json); + } + + private Map send(String method, String path, String json) { + ConnectionRequest request = new ConnectionRequest(); + request.setUrl(AppConfig.serverUrl() + path); + request.setHttpMethod(method); + request.setPost(json != null); + if (json != null) { + request.setContentType("application/json"); + request.setRequestBody(json); + } + request.addRequestHeader("Authorization", "Bearer " + token); + request.setAuthorizer(RequestAuthorizer.NONE); + request.setReadResponseForErrors(true); + request.setFailSilently(true); + request.setDuplicateSupported(true); + NetworkManager.getInstance().addToQueueAndWait(request); + status = request.getResponseCode(); + byte[] body = request.getResponseData(); + // A list comes back as the parser gives it: under "root". + if (body == null || body.length == 0 || body[0] != '{' && body[0] != '[') { + return new HashMap(); + } + try { + return new JSONParser().parseJSON(new CharArrayReader( + new String(body, "UTF-8").toCharArray())); + } catch (IOException malformed) { + return new HashMap(); + } + } + + /// What `path` answers when it answers a list. + java.util.List list(String path) { + Object root = get(path).get("root"); + return root instanceof java.util.List ? (java.util.List) root + : new java.util.ArrayList(); + } + + static String text(Map json, String key) { + Object value = json.get(key); + return value instanceof String ? (String) value : ""; + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/E2e.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/E2e.java new file mode 100644 index 00000000000..4281e3dee89 --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/E2e.java @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.components.SpanLabel; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.TokenStore; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.spinner.Picker; +import com.codename1.ui.util.ImageIO; +import com.codename1.util.AsyncResource; +import com.codenameone.examples.pulse.live.LiveChannel; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Lang; +import com.codenameone.examples.pulse.ui.Look; +import com.codenameone.examples.pulse.ui.Nav; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Calendar; +import java.util.List; +import java.util.Map; + +/// What the end-to-end tests share. Each of them drives the real app, through +/// its real screens, against a real server -- the one `run-e2e.sh` starts on the +/// `test` profile, which has the four demo accounts, a geocoder that knows ten +/// places and a text-message sender that hands the code back. +/// +/// Three things are pinned so that a run is the same wherever it happens: the +/// map draws from tiles bundled with the tests, a route is the straight line, +/// and the device stands at a fixed point. None of it touches the server. +public abstract class E2e extends AbstractTest { + public static final String PASSWORD = "pulse-demo"; + public static final String MEMBER = "member@pulse.example"; + public static final String DESK = "desk@pulse.example"; + public static final String TRAINER = "trainer@pulse.example"; + public static final String OWNER = "owner@pulse.example"; + + protected interface Check { + boolean ok(); + } + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + @Override + public int getTimeoutMillis() { + return 180000; + } + + /// Whether this run of the tests is the one in a desktop window. + /// + /// `run-e2e.sh` runs the tests twice: on the simulator's phone, and then + /// with the simulator as a desktop, where the app is a window an admin + /// works in. A test belongs to one of the two -- see [#desktop()] -- and + /// passes without doing anything in the other. + static boolean desktopRun() { + return "true".equals(System.getProperty("pulse.e2e.desktop")); + } + + /// Whether this test is one for the desktop window. The phone's are not. + protected boolean desktop() { + return false; + } + + private boolean mine() { + return desktop() == desktopRun(); + } + + /// The test itself. + protected abstract boolean run() throws Exception; + + @Override + public final boolean runTest() throws Exception { + if (!mine()) { + log("Not a test for " + (desktopRun() ? "the desktop" : "the phone") + "; skipped"); + return true; + } + return run(); + } + + @Override + public void prepare() { + if (!mine()) { + return; + } + Session.useStore(new MemoryStore()); + onEdt(() -> { + LiveChannel.stop(); + // The tests read the screens in English, whatever the machine + // they run on speaks. + Prefs.setLanguage("en"); + Prefs.setTheme(Prefs.THEME_LIGHT); + // Answered, with a no: no test is stopped by the question a first + // sign-in is asked, and none reports how it used the app. The + // test of the reports gives its own answers. + Telemetry.allow(false); + Look.apply(); + Lang.apply(); + Session.start(AppConfig.serverUrl()); + AppConfig.setMode(Nav.MEMBER); + Nav.launch(); + }); + waitForForm("Welcome"); + quiet(); + } + + /// Puts the server back where a test expects to find it: the member with + /// no class booked. The tests share one server, and + /// without this a test that failed half way would fail the ones after it + /// for a reason that has nothing to do with them. + private void quiet() { + Actor member = Actor.signIn(MEMBER); + for (Object booking : member.list("/api/bookings")) { + if (booking instanceof Map) { + String id = Actor.text((Map) booking, "id"); + if (id.length() > 0) { + member.post("/api/bookings/" + id + "/cancel", null); + } + } + } + } + + @Override + public void cleanup() { + if (!mine()) { + return; + } + onEdt(() -> { + LiveChannel.stop(); + Session.start(AppConfig.serverUrl()); + }); + } + + // ------------------------------------------------------------- onboarding + + protected void onEdt(Runnable work) { + CN.callSeriallyAndWait(work); + } + + /// Clicks the button called `name` on the screen that is showing, once it + /// is there. + protected void click(final String name) { + until(() -> button(name) != null, 20000, "a button called " + name); + onEdt(() -> { + Button button = button(name); + button.pressed(); + button.released(); + }); + } + + /// The button called `name`, or null. A tappable card is named like its + /// lead button and is found first by name, so the tree is searched for the + /// button itself. + /// + /// The tree is walked on the event thread: walked from the test's thread, + /// a screen the event thread is filling changes a container's children + /// under the iteration and throws ConcurrentModificationException. + protected Button button(final String name) { + if (CN.isEdt()) { + Form current = Display.getInstance().getCurrent(); + return current == null ? null : button(current, name); + } + final Button[] found = new Button[1]; + onEdt(() -> found[0] = button(name)); + return found[0]; + } + + private static Button button(Component from, String name) { + if (from instanceof Button && name.equals(from.getName())) { + return (Button) from; + } + if (from instanceof Container) { + for (Component child : (Container) from) { + Button found = button(child, name); + if (found != null) { + return found; + } + } + } + return null; + } + + /// Clicks the button called `name` if it is on the screen at this moment. + /// + /// For a button the server may take away while the test looks: asking + /// whether it is there and then clicking it are two turns of the event + /// thread, and the screen can change between them. + protected void clickIfThere(final String name) { + onEdt(() -> { + Component found = findByName(name); + if (found instanceof Button) { + Button button = (Button) found; + button.pressed(); + button.released(); + } + }); + } + + protected void type(final String name, final String text) { + until(() -> findByName(name) instanceof TextArea, 20000, "a field called " + name); + onEdt(() -> { + TextArea field = (TextArea) findByName(name); + field.setText(text); + }); + } + + /// The text of the label or field called `name`, or "" while there is none. + protected String text(String name) { + Component found = findByName(name); + if (found instanceof SpanLabel) { + return ((SpanLabel) found).getText(); + } + if (found instanceof Label) { + return ((Label) found).getText(); + } + if (found instanceof TextArea) { + return ((TextArea) found).getText(); + } + return ""; + } + + protected void until(Check check, int timeoutMillis, String what) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (!check.ok()) { + if (System.currentTimeMillis() > deadline) { + Form current = Display.getInstance().getCurrent(); + // What the screen showed, beside the captures: a timeout on CI + // is otherwise only its message. + try { + capture("timed-out-" + (current == null ? "no-form" : current.getName())); + } catch (java.io.IOException err) { + log("Could not capture the timeout: " + err); + } + throw new RuntimeException("Timed out waiting for " + what + " (on " + + (current == null ? "no form" : current.getName()) + ")"); + } + waitFor(100); + } + } + + /// Waits for the screen called `name` to be the one showing. + protected void waitForForm(final String name) { + until(() -> { + Form current = Display.getInstance().getCurrent(); + return current != null && name.equals(current.getName()); + }, 30000, "the " + name + " screen"); + waitFor(100); + } + + protected void untilText(final String name, final String expected) { + until(() -> expected.equals(text(name)), 30000, + name + " to read \"" + expected + "\" (it reads \"" + text(name) + "\")"); + } + + /// Signs in through the app's own screens. + protected void signIn(String email, String homeForm) { + click("signIn"); + waitForForm("SignIn"); + type("email", email); + type("password", PASSWORD); + click("submit"); + waitForForm(homeForm); + } + + /// Whether there is something to press called `name` on the screen. + protected boolean has(String name) { + return findByName(name) instanceof Button; + } + + /// Scrolls whatever is called `name` into view. + protected void reveal(final String name) { + until(() -> findByName(name) != null, 20000, name + " to be there"); + onEdt(() -> { + Component found = findByName(name); + // The nearest thing above it that scrolls, which on a home screen + // is not the form. + Container holder = found.getParent(); + while (holder != null && !holder.isScrollableY()) { + holder = holder.getParent(); + } + if (holder != null) { + holder.scrollComponentToVisible(found); + } + }); + } + + /// Goes back, the way the arrow in the title bar does. + protected void back() { + onEdt(() -> Display.getInstance().getCurrent().getBackCommand().actionPerformed(null)); + } + + /// Signs out through the menu of a home screen. + protected void signOut() { + click("menu-open"); + click("menu-signOut"); + waitForForm("Welcome"); + } + + /// Sets the date field called `name`. A test cannot turn the wheels of + /// the platform's date picker, which is not part of the app. + protected void date(final String name, final int year, final int month, final int day) { + until(() -> findByName(name) instanceof Picker, 20000, "a date called " + name); + onEdt(() -> { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + ((Picker) findByName(name)).setDate(calendar.getTime()); + }); + } + + /// A picture for the app to take as a photograph: the tests have neither + /// a camera nor a gallery. It is drawn rather than read from a file so + /// that it is the same picture wherever the tests run. + protected static Image photograph() { + Image picture = Image.createImage(640, 400, 0xffe9edf2); + Graphics g = picture.getGraphics(); + g.setColor(0x2f6fed); + g.fillRect(0, 0, 640, 70); + g.setColor(0xb8c2cf); + g.fillRect(40, 110, 150, 190); + g.setColor(0x8795a7); + for (int line = 0; line < 5; line++) { + g.fillRect(230, 120 + line * 38, line % 2 == 0 ? 340 : 250, 16); + } + return picture; + } + + // -------------------------------------------------------- screenshots + + /// Captures the screen as `name`.png and holds it against the golden of + /// the same name. + /// + /// `pulse.shots.dir` is where the capture goes; without it nothing is + /// captured, which is a run from an IDE. `pulse.goldens.dir` is where + /// the goldens are. A capture with no golden passes -- that is how a new + /// one is made: run, look at it, copy it over. + /// + /// The comparison is loose on purpose: a pixel counts as different only + /// when it is far off, and a capture fails only when a twentieth of it is. + /// Text is drawn a little differently from one machine to the next, and + /// this is here to notice a screen that changed, not a glyph. + /// Saves the screen as `name`.png in the captures, and answers it; null + /// when the run keeps no captures. + private Image capture(String name) throws java.io.IOException { + String out = fileUrl(System.getProperty("pulse.shots.dir")); + if (out == null) { + return null; + } + final Image[] captured = new Image[1]; + onEdt(() -> captured[0] = Display.getInstance().captureScreen()); + FileSystemStorage files = FileSystemStorage.getInstance(); + files.mkdir(out); + OutputStream png = files.openOutputStream(out + "/" + name + ".png"); + try { + ImageIO.getImageIO().save(captured[0], png, ImageIO.FORMAT_PNG, 1f); + } finally { + png.close(); + } + return captured[0]; + } + + protected void shot(String name) { + String out = fileUrl(System.getProperty("pulse.shots.dir")); + if (out == null) { + return; + } + settle(); + FileSystemStorage files = FileSystemStorage.getInstance(); + try { + Image[] captured = {capture(name)}; + String goldens = fileUrl(System.getProperty("pulse.goldens.dir")); + String golden = goldens == null ? "" : goldens + "/" + name + ".png"; + if (golden.length() == 0 || !files.exists(golden)) { + log("No golden for " + name + "; captured only"); + return; + } + InputStream in = files.openInputStream(golden); + Image expected; + try { + expected = Image.createImage(in); + } finally { + in.close(); + } + assertEqual(expected.getWidth(), captured[0].getWidth(), name + ": width changed"); + assertEqual(expected.getHeight(), captured[0].getHeight(), name + ": height changed"); + int[] want = expected.getRGB(); + int[] got = captured[0].getRGB(); + int off = 0; + for (int iter = 0; iter < want.length; iter++) { + if (far(want[iter], got[iter])) { + off++; + } + } + assertTrue(off * 20L <= want.length, name + ": " + off + " of " + want.length + + " pixels differ from the golden"); + } catch (java.io.IOException err) { + throw new RuntimeException("Could not capture " + name + ": " + err); + } + } + + /// A directory the build names as an ordinary path, in the form + /// `FileSystemStorage` takes one; null when it names none. + private static String fileUrl(String path) { + if (path == null || path.length() == 0) { + return null; + } + return path.startsWith("file:") ? path : "file://" + path; + } + + private static boolean far(int a, int b) { + return Math.abs(((a >> 16) & 0xff) - ((b >> 16) & 0xff)) > 60 + || Math.abs(((a >> 8) & 0xff) - ((b >> 8) & 0xff)) > 60 + || Math.abs((a & 0xff) - (b & 0xff)) > 60; + } + + /// Waits for the screen to stop moving: transitions and the level ring's + /// sweep over. + private void settle() { + waitFor(2000); + } + + /// Tokens kept for the length of the run and nowhere else. + static final class MemoryStore implements TokenStore { + private OidcTokens kept; + + @Override + public AsyncResource load(String key) { + AsyncResource out = new AsyncResource(); + out.complete(kept); + return out; + } + + @Override + public AsyncResource save(String key, OidcTokens tokens) { + kept = tokens; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + + @Override + public AsyncResource clear(String key) { + kept = null; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/OwnerTest.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/OwnerTest.java new file mode 100644 index 00000000000..22db3a1ceb5 --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/OwnerTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.ui.spinner.Picker; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; + +/// The owner opens the dashboard and puts a class on the timetable from the +/// screens; the server is then asked whether the class is there. +public class OwnerTest extends E2e { + @Override + @SuppressWarnings("unchecked") + protected boolean run() throws Exception { + Actor owner = Actor.signIn(OWNER); + // Early on a day weeks away, so the class clashes with nothing the + // demo timetable or an earlier run put there. + Calendar at = Calendar.getInstance(); + at.setTime(new Date(System.currentTimeMillis() + + (40 + System.currentTimeMillis() / 60000L % 30) * 24L * 3600000L)); + at.set(Calendar.HOUR_OF_DAY, 5); + at.set(Calendar.MINUTE, 15); + at.set(Calendar.SECOND, 0); + at.set(Calendar.MILLISECOND, 0); + final long startsAt = at.getTime().getTime(); + + // The suite starts every test in the member's mode; the owner opens + // the mode the account is for. + onEdt(() -> AppConfig.setMode("")); + signIn(OWNER, "Owner"); + until(() -> findByName("revenueChart") != null, 20000, "the dashboard's charts"); + shot("owner-dashboard"); + click("menu-open"); + click("menu-timetable"); + waitForForm("Timetable"); + click("scheduleClass"); + waitForForm("ScheduleClass"); + until(() -> findByName("startsAt") instanceof Picker, 20000, "the class's time"); + onEdt(() -> ((Picker) findByName("startsAt")).setDate(new Date(startsAt))); + type("capacity", "1"); + shot("owner-schedule"); + click("schedule"); + waitForForm("Timetable"); + + String made = ""; + for (Object found : owner.list("/api/studio/classes?location=&from=" + (startsAt - 60000L) + + "&days=1")) { + if (found instanceof Map && ((Map) found).get("startsAt") + instanceof Number && ((Number) ((Map) found).get("startsAt")) + .longValue() == startsAt) { + made = Actor.text((Map) found, "id"); + } + } + assertTrue(made.length() > 0, "the class is on the timetable"); + owner.post("/api/owner/classes/" + made + "/cancel", "{\"reason\":\"End of the owner test\"}"); + return true; + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/RegisterTest.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/RegisterTest.java new file mode 100644 index 00000000000..30d0a4460eb --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/RegisterTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codenameone.examples.pulse.net.Session; + +/// A new account: register, be signed in, verify the phone, arrive. +public class RegisterTest extends E2e { + @Override + protected boolean run() throws Exception { + // One address per pass, not per run: the server is new for every run, both + // passes share it, and a timestamp in the address wrapped differently from + // run to run in the screens that are compared. + String email = "new." + (desktopRun() ? "desk" : "phone") + "@pulse.example"; + click("register"); + waitForForm("Register"); + type("name", "Nico Newcomer"); + type("email", email); + type("phone", "+1 555 010 " + (System.currentTimeMillis() % 9000 + 1000)); + type("birthDate", "1990-04-12"); + type("password", PASSWORD); + shot("register"); + click("submit"); + + // Registered and signed in, and asked for the code. The test server + // sends no text; it says what the code is. + waitForForm("VerifyPhone"); + assertEqual(email, Session.user().username()); + assertFalse(Session.user().phoneVerified(), "a new account starts unverified"); + until(() -> text("demoCode").indexOf("code is ") > 0, 20000, "the demo code"); + shot("verify-phone"); + String notice = text("demoCode"); + int at = notice.indexOf("code is ") + 8; + String code = notice.substring(at, at + 6); + + // A wrong code is refused by the server, and says so. + type("code", "000000".equals(code) ? "111111" : "000000"); + click("submit"); + until(() -> text("error").length() > 0, 20000, "the wrong-code error"); + assertFalse(Session.user().phoneVerified()); + + type("code", code); + click("submit"); + waitForForm("Member"); + assertTrue(Session.user().phoneVerified(), "the right code verifies the number"); + return true; + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SettingsTest.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SettingsTest.java new file mode 100644 index 00000000000..a385dc25050 --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SettingsTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.components.Switch; +import java.util.Map; + +/// The settings: a language and a theme that change the app on the spot, and +/// class reminders that are kept with the account. +public class SettingsTest extends E2e { + @Override + protected boolean run() throws Exception { + final Actor member = Actor.signIn(MEMBER); + signIn(MEMBER, "Member"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + untilText("signOut", "Sign out"); + shot("settings"); + + // Another language, at once and on the account. + click("language-he"); + until(() -> text("signOut").length() > 0 && !"Sign out".equals(text("signOut")), 20000, + "the screen to be in Hebrew"); + until(() -> "he".equals(Actor.text(member.get("/api/account/preferences"), "language")), + 20000, "the language to be kept with the account"); + shot("settings-hebrew"); + click("language-en"); + untilText("signOut", "Sign out"); + + // Dark, and the screen behind is dark as well. + click("theme-dark"); + until(() -> "dark".equals(Actor.text(member.get("/api/account/preferences"), "theme")), + 20000, "the theme to be kept with the account"); + shot("settings-dark"); + back(); + waitForForm("Member"); + shot("member-home-dark"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + click("theme-system"); + + // Class reminders off, on the account, and back on. + flip("notifyClasses"); + until(() -> "false".equals(String.valueOf(member.get("/api/account/preferences") + .get("notifyClasses"))), 20000, "the reminders to be off on the account"); + flip("notifyClasses"); + until(() -> { + Map kept = member.get("/api/account/preferences"); + return "true".equals(String.valueOf(kept.get("notifyClasses"))) + && "system".equals(Actor.text(kept, "theme")) + && "en".equals(Actor.text(kept, "language")); + }, 20000, "the account to hold what was chosen"); + click("signOut"); + waitForForm("Welcome"); + return true; + } + + /// Turns the switch called `name` the other way, as a tap on it does. + private void flip(final String name) { + until(() -> findByName(name) instanceof Switch, 20000, "a switch called " + name); + onEdt(() -> { + Switch toggle = (Switch) findByName(name); + toggle.setValue(!toggle.isValue()); + }); + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SignInTest.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SignInTest.java new file mode 100644 index 00000000000..0efff2930bb --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/SignInTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.ui.Button; +import com.codenameone.examples.pulse.net.Session; +import com.codenameone.examples.pulse.ui.Nav; + +/// Signing in, and what a role does and does not open. +public class SignInTest extends E2e { + @Override + protected boolean run() throws Exception { + shot("welcome"); + + // A wrong password says so and stays put. + click("signIn"); + waitForForm("SignIn"); + type("email", MEMBER); + type("password", "not-the-password"); + click("submit"); + until(() -> text("error").length() > 0, 20000, "the sign-in error"); + assertEqual("SignIn", com.codename1.ui.Display.getInstance().getCurrent().getName()); + assertNull(Session.user(), "nobody is signed in after a wrong password"); + + // The right one lands a member on the member's home. + type("password", PASSWORD); + click("submit"); + waitForForm("Member"); + assertEqual(MEMBER, Session.user().username()); + assertTrue(Session.user().member() && !Session.user().desk() && !Session.user().owner() + && !Session.user().trainer(), "the demo member has the member role and no other"); + shot("member-home"); + + // Asking for the desk's or the owner's screens without the role gets + // the member's. + onEdt(() -> Nav.switchTo(Nav.DESK)); + waitForForm("Member"); + onEdt(() -> Nav.switchTo(Nav.OWNER)); + waitForForm("Member"); + // And the server agrees, which is the part that matters: the same + // person's token is refused on the desk's and the owner's API. + Actor member = Actor.signIn(MEMBER); + member.get("/api/desk/today?location="); + assertEqual(403, member.status, "a member asking for the desk's day"); + member.get("/api/me"); + assertEqual(200, member.status); + + // The menu offers no mode to switch to, and leads to the settings. + assertFalse(findByName("switch-desk") instanceof Button, "a member is offered the desk"); + assertFalse(findByName("switch-owner") instanceof Button, "a member is offered owner mode"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + click("signOut"); + waitForForm("Welcome"); + assertNull(Session.user()); + return true; + } +} diff --git a/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/TrainerTest.java b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/TrainerTest.java new file mode 100644 index 00000000000..4ad99a274fa --- /dev/null +++ b/scripts/pulse/common/src/test/java/com/codenameone/examples/pulse/TrainerTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse; + +import com.codename1.components.Switch; +import java.util.List; +import java.util.Map; + +/// A trainer marks a member present from the class roster. +/// +/// The class is made for the test, through the owner's API, to start within +/// the hour: attendance can be marked from an hour before a class, and none +/// of the demo timetable is guaranteed to be that close. The member books it the +/// same way, so all the test drives through the screens is the trainer's part. +public class TrainerTest extends E2e { + @Override + @SuppressWarnings("unchecked") + protected boolean run() throws Exception { + Actor owner = Actor.signIn(OWNER); + Actor trainer = Actor.signIn(TRAINER); + Actor member = Actor.signIn(MEMBER); + String coach = Actor.text(trainer.get("/api/me"), "username"); + String sessionId = schedule(owner, coach, clearTrainersHour(owner, trainer)); + assertTrue(sessionId.length() > 0, "a class for the trainer could be scheduled"); + try { + Map booking = member.post("/api/bookings", + "{\"sessionId\":\"" + sessionId + "\"}"); + assertEqual(200, member.status, "the member books the class"); + final String bookingId = Actor.text(booking, "id"); + + onEdt(() -> AppConfig.setMode("")); + signIn(TRAINER, "Trainer"); + click("class-" + sessionId); + waitForForm("Roster"); + until(() -> findByName("present-" + bookingId) instanceof Switch, 20000, + "the member on the roster"); + shot("trainer-roster"); + onEdt(() -> ((Switch) findByName("present-" + bookingId)).setValue(true)); + until(() -> checkedIn(trainer, sessionId, bookingId), 20000, + "the member to be marked present"); + return true; + } finally { + owner.post("/api/owner/classes/" + sessionId + "/cancel", + "{\"reason\":\"End of the trainer test\"}"); + } + } + + /// Makes the next hour the trainer's own and answers when a class may + /// start in it. The demo studio keeps the trainer busy all day, and a + /// trainer cannot be in two classes at once: the classes still to come in + /// that hour are cancelled (this server is the test's), and one already + /// under way can only be waited out, so the new class starts after it. + /// Attendance is open from an hour before a class, so it starts within it. + @SuppressWarnings("unchecked") + private static long clearTrainersHour(Actor owner, Actor trainer) { + long now = System.currentTimeMillis(); + long start = now + 10 * 60000L; + for (Object found : trainer.list("/api/trainer/classes")) { + if (!(found instanceof Map)) { + continue; + } + Map session = (Map) found; + long begins = ((Number) session.get("startsAt")).longValue(); + long ends = ((Number) session.get("endsAt")).longValue(); + if (!"SCHEDULED".equals(Actor.text(session, "state")) || ends <= now + || begins > now + 3 * 3600000L) { + continue; + } + if (begins > now) { + owner.post("/api/owner/classes/" + Actor.text(session, "id") + "/cancel", + "{\"reason\":\"Clearing the trainer's hour for the trainer test\"}"); + } else { + start = Math.max(start, ends + 2 * 60000L); + } + } + return start; + } + + @SuppressWarnings("unchecked") + private static boolean checkedIn(Actor trainer, String sessionId, String bookingId) { + for (Object entry : trainer.list("/api/trainer/classes/" + sessionId + "/roster")) { + if (entry instanceof Map && bookingId.equals(Actor.text((Map) entry, + "bookingId"))) { + return "CHECKED_IN".equals(Actor.text((Map) entry, "state")); + } + } + return false; + } + + /// Schedules one class for `trainer` at `startsAt`, in the first room + /// that is free then, and answers its id ("" when none was). + @SuppressWarnings("unchecked") + static String schedule(Actor owner, String trainer, long startsAt) { + List types = owner.list("/api/studio/types"); + List rooms = owner.list("/api/owner/rooms"); + String type = ""; + for (Object found : types) { + // A kind of class an adult can book: the demo member is one. + if (found instanceof Map && number((Map) found, "minAge") <= 18 + && (number((Map) found, "maxAge") == 0 + || number((Map) found, "maxAge") >= 60)) { + type = Actor.text((Map) found, "id"); + break; + } + } + if (type.length() == 0) { + return ""; + } + for (Object room : rooms) { + if (!(room instanceof Map)) { + continue; + } + Map made = owner.post("/api/owner/classes", "{\"classTypeId\":\"" + + type + "\",\"roomId\":\"" + Actor.text((Map) room, "id") + + "\",\"trainer\":\"" + trainer + "\",\"startsAt\":" + startsAt + + ",\"capacity\":" + number((Map) room, "capacity") + + ",\"weeks\":1}"); + Object list = made.get("root"); + if (owner.status == 200 && list instanceof List && !((List) list).isEmpty() + && ((List) list).get(0) instanceof Map) { + return Actor.text((Map) ((List) list).get(0), "id"); + } + } + return ""; + } + + private static int number(Map json, String key) { + Object value = json.get(key); + return value instanceof Number ? ((Number) value).intValue() : 0; + } +} diff --git a/scripts/pulse/pom.xml b/scripts/pulse/pom.xml new file mode 100644 index 00000000000..36fbe4c4879 --- /dev/null +++ b/scripts/pulse/pom.xml @@ -0,0 +1,314 @@ + + 4.0.0 + com.codenameone.examples.pulse + pulse + 1.0-SNAPSHOT + pom + pulse + pulse + https://www.codenameone.com + + + MIT No Attribution + https://opensource.org/license/mit-0 + repo + The same terms as the LICENSE file beside this pom + + + + + shared + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 1.8 + 11 + 1.7.11 + 3.8.0 + 1.8 + 1.8 + 1.8 + pulse + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-android-compat + ${cn1.version} + provided + + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + + + cn1-client-tests + + + codename1.platform + !backend + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + javascript + + + codename1.platform + javascript + + + ${basedir}/javascript/pom.xml + + + + javascript + + + + ios + + + codename1.platform + ios + + + ${basedir}/ios/pom.xml + + + + ios + + + + win + + + codename1.platform + win + + + ${basedir}/win/pom.xml + + + + win + + + + linux + + + codename1.platform + linux + + + ${basedir}/linux/pom.xml + + + + linux + + + + + backend + + + codename1.platform + backend + + + ${basedir}/backend/pom.xml + + + + backend + + + + android + + + codename1.platform + android + + + ${basedir}/android/pom.xml + + + + android + + + + javase + + + codename1.platform + javase + + + ${basedir}/javase/pom.xml + + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/pulse/run-e2e.sh b/scripts/pulse/run-e2e.sh new file mode 100755 index 00000000000..fedfec15f97 --- /dev/null +++ b/scripts/pulse/run-e2e.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# The end-to-end tests: starts the Pulse server on the `test` profile, then +# runs the app's test suite against it in the Codename One simulator. +# +# run-e2e.sh [--native] [--prebuilt] +# +# --native runs the tests against the compiled server +# --prebuilt uses the server as it was last built, without building it again +# +# The tests drive the real screens: they sign in, book a class, check in at the +# desk and run the studio, while a second user acts through the API. They capture a few +# screens into target/e2e-shots and hold them against screenshots/. +# +# Needs JAVA_HOME on JDK 17 or newer, and the Codename One artifacts this project +# builds against in the local Maven repository. The simulator needs a display: +# xvfb-run is used when there is one, which is what makes this run on a CI +# machine and in a container. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${MVN:-mvn}" +PORT="${PULSE_PORT:-18080}" +LOG="${PULSE_SERVER_LOG:-$HERE/target/e2e-server.log}" +SHOTS="$HERE/target/e2e-shots" +MODE="--jvm" +BUILD=1 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) BUILD=0 ;; + *) echo "[pulse-e2e] unknown option: $arg" >&2; exit 2 ;; + esac +done + +mkdir -p "$HERE/target" +rm -rf "$SHOTS" +mkdir -p "$SHOTS" + +echo "[pulse-e2e] java: $(java -version 2>&1 | head -1)" +if [ "$BUILD" = 1 ]; then + "$HERE/backend/server.sh" build "$MODE" +fi + +echo "[pulse-e2e] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[pulse-e2e] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +RUNNER=() +if command -v xvfb-run >/dev/null 2>&1; then + RUNNER=(xvfb-run -a -s "-screen 0 1280x1024x24") +fi + +# The simulator is a JVM the build forks, and JAVA_TOOL_OPTIONS is what reaches +# it: where the server is, where the captures go and where the goldens are. +# The heap is given a ceiling too. Left alone a JVM takes a quarter of the +# machine's memory before it collects anything, which on a shared runner is +# enough to have the kernel end it between two tests. +export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS:-} -Xmx768m -Dpulse.server.url=http://localhost:$PORT -Dpulse.shots.dir=$SHOTS -Dpulse.goldens.dir=$HERE/screenshots" + +set +e +${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify +STATUS=$? +set -e + +# The second pass: the same tests with the simulator as a desktop window and +# not a phone, which is where the admin console is laid out side by side and +# wears the platform's own theme. Each test knows which pass it is for and +# passes untouched in the other (E2e.desktop()). +# +# The simulator is a desktop when its "desktopSkin" preference says so; there +# is no switch for it on the command line. Java keeps preferences in files only +# on Linux, where java.util.prefs.userRoot can point them at a directory of +# this run's own -- so that is where this pass runs, which covers CI and a +# container. Elsewhere it would mean rewriting the developer's own simulator +# preferences, and it is skipped with a word. +if [ "$STATUS" -eq 0 ] && [ "$(uname -s)" = "Linux" ]; then + PREFS="$HERE/target/e2e-desktop-prefs" + NODE="$PREFS/.java/.userPrefs/com/codename1/impl/javase" + rm -rf "$PREFS" + mkdir -p "$NODE" + cat >"$NODE/prefs.xml" <<'XML' + + + + + +XML + echo "[pulse-e2e] the desktop pass" + # The last two are the project's own build hints, from + # common/codenameone_settings.properties. A packaged desktop app is given + # them by its launcher; a test run starts the simulator in + # common/target/cn1-reports, from where it does not find the project's + # settings, so it has to be told. + export JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -Dpulse.e2e.desktop=true -Djava.util.prefs.userRoot=$PREFS -Dcodename1.arg.nativeTheme=native -Dcodename1.arg.desktop.titleBar=native" + set +e + ${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify + STATUS=$? + set -e +elif [ "$STATUS" -eq 0 ]; then + echo "[pulse-e2e] not on Linux: the desktop pass was skipped" +fi + +if [ "$STATUS" -ne 0 ]; then + echo "[pulse-e2e] the tests failed; the end of the server's log:" >&2 + tail -n 80 "$LOG" >&2 || true + exit "$STATUS" +fi +echo "[pulse-e2e] passed; captures are in $SHOTS" diff --git a/scripts/pulse/run-web-e2e.sh b/scripts/pulse/run-web-e2e.sh new file mode 100755 index 00000000000..bbf55de1d28 --- /dev/null +++ b/scripts/pulse/run-web-e2e.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# The web app, end to end: builds the Pulse server and the browser build of +# the app, starts the server with the app staged where it serves it from, and +# drives the hosted app in a headless browser (web-e2e.mjs). +# +# run-web-e2e.sh [--native] [--prebuilt] +# +# --native runs the compiled server; --prebuilt skips both builds and uses what +# `backend/server.sh build --web` left. +# +# What it proves: a person who opens the server's address gets the app, can sign +# in as the demo customer and reaches the home screen, with the live channel open. +# Screenshots of each step and the browser's log go to target/web-e2e. +# +# Needs JAVA_HOME on JDK 17 or newer, the Codename One artifacts this project builds +# against in the local Maven repository, and Node with Playwright and its +# Chromium: `npm ci && npx playwright install chromium` in scripts/, or a +# NODE_PATH that has it. Nothing here opens a window. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${PULSE_WEB_PORT:-18081}" +OUT="${PULSE_WEB_OUT:-$HERE/target/web-e2e}" +LOG="$OUT/server.log" +MODE="--jvm" +PREBUILT=0 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) PREBUILT=1 ;; + *) echo "[pulse-web] unknown argument $arg" >&2; exit 2 ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[pulse-web] node is not on the PATH; the browser is driven with Playwright" >&2 + exit 2 +fi + +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "[pulse-web] java: $(java -version 2>&1 | head -1)" +if [ "$PREBUILT" = 0 ]; then + "$HERE/backend/server.sh" build "$MODE" --web +fi +if [ ! -f "$HERE/backend/target/webapp/index.html" ]; then + echo "[pulse-web] no web app is staged in backend/target/webapp" >&2 + exit 1 +fi + +echo "[pulse-web] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[pulse-web] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +fail() { + echo "[pulse-web] FAILED: $*" >&2 + exit 1 +} + +# What no browser is needed to see: the app is at /, the API kept its paths, and +# the script that is most of the download goes out compressed. +headers="$(curl -fsS -D - -o /dev/null "http://localhost:$PORT/")" \ + || fail "the server did not answer at /" +echo "$headers" | grep -qi '^content-type: text/html' || fail "/ is not served as a page" +echo "$headers" | grep -qi '^cache-control: no-cache' || fail "/ is not revalidated" +headers="$(curl -fsS -H 'Accept-Encoding: gzip' -D - -o /dev/null \ + "http://localhost:$PORT/translated_app.js")" || fail "translated_app.js is not served" +echo "$headers" | grep -qi '^content-encoding: gzip' \ + || fail "translated_app.js was not served compressed" +status="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/me")" +[ "$status" = "401" ] || fail "/api/me answered $status without a token, not 401" + +set +e +URL="http://localhost:$PORT/" OUT="$OUT" node "$HERE/web-e2e.mjs" +STATUS=$? +set -e + +if [ "$STATUS" -ne 0 ]; then + echo "[pulse-web] the end of the browser's log:" >&2 + tail -n 40 "$OUT/browser.log" >&2 || true + echo "[pulse-web] the end of the server's log:" >&2 + tail -n 40 "$LOG" >&2 || true + exit "$STATUS" +fi +echo "[pulse-web] passed; screenshots are in $OUT" diff --git a/scripts/pulse/shared/pom.xml b/scripts/pulse/shared/pom.xml new file mode 100644 index 00000000000..1697a6cb3d9 --- /dev/null +++ b/scripts/pulse/shared/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.codenameone.examples.pulse + pulse + 1.0-SNAPSHOT + + com.codenameone.examples.pulse + pulse-shared + 1.0-SNAPSHOT + jar + pulse-shared + + + + 17 + 17 + + + + + com.codenameone + codenameone-core + provided + + + diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountApi.java new file mode 100644 index 00000000000..0970378639c --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountApi.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Opening an account, proving a phone number, asking who is signed in, and the +/// settings an account keeps. +/// +/// The app calls these through the generated client. The server implements the +/// generated `AccountApiServer`, and its build routes each path to that +/// implementation; neither side writes the other's half. +@RestClient +public interface AccountApi { + /// Opens an account. The one call here that needs no sign-in. + @POST("/api/account/register") + void register(@Body RegisterDto request, OnComplete> callback); + + @GET("/api/me") + void me(OnComplete> callback); + + /// Sends a verification code to the phone number on the account. + @POST("/api/account/phone/start") + void startPhoneVerification(OnComplete> callback); + + @POST("/api/account/phone/verify") + void verifyPhone(@Body PhoneCodeDto code, OnComplete> callback); + + /// A ticket for the live channel; see [TicketDto]. + @POST("/api/live/ticket") + void liveTicket(OnComplete> callback); + + @GET("/api/account/preferences") + void preferences(OnComplete> callback); + + @PUT("/api/account/preferences") + void savePreferences(@Body PreferencesDto preferences, + OnComplete> callback); + + @GET("/api/account/profile") + void profile(OnComplete> callback); + + @PUT("/api/account/profile") + void saveProfile(@Body ProfileDto profile, OnComplete> callback); + + @POST("/api/account/password") + void changePassword(@Body PasswordChangeDto change, OnComplete> callback); + + /// Closes the account for good. Refused while an invoice is being charged, and + /// without the account's password: a token is what a borrowed phone + /// holds too, and this cannot be taken back. + @POST("/api/account/delete") + void deleteAccount(@Body AccountClosureDto closure, OnComplete> callback); + + // tag::pulse-push-contract[] + /// Records a device the signed-in account is to be sent notifications on. + /// Registering the same token again moves it to this account: a phone + /// belongs to whoever signed in on it last. + @POST("/api/account/push") + void registerPush(@Body PushDeviceDto device, OnComplete> callback); + + /// Forgets a device, on signing out: the next person to use the phone is + /// not to be told about this account's classes. + @POST("/api/account/push/remove") + void unregisterPush(@Body PushDeviceDto device, OnComplete> callback); + // end::pulse-push-contract[] +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountClosureDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountClosureDto.java new file mode 100644 index 00000000000..54e9d3bded0 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AccountClosureDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The request to close an account: the password of whoever is asking. +@Mapped +public record AccountClosureDto(String password) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AdminApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AdminApi.java new file mode 100644 index 00000000000..6200414a4a3 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AdminApi.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Accounts, for the OWNER role: who is a member, who works here and in what +/// role, and who is blocked. +@RestClient +public interface AdminApi { + @GET("/api/admin/users") + void users(OnComplete>> callback); + + @GET("/api/admin/users/{username}") + void user(@Path("username") String username, OnComplete> callback); + + @POST("/api/admin/users/{username}/roles") + void setRoles(@Path("username") String username, @Body RoleChangeDto roles, + OnComplete> callback); + + /// Blocks an account, or lifts the block. + @POST("/api/admin/users/{username}/suspend") + void suspend(@Path("username") String username, @Body SuspendDto suspend, + OnComplete> callback); + + @POST("/api/admin/users/{username}/flag") + void flag(@Path("username") String username, @Body FlagDto flag, + OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AttendanceDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AttendanceDto.java new file mode 100644 index 00000000000..c568fa6bd6f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AttendanceDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A trainer marking someone in, or as a no-show. +@Mapped +public record AttendanceDto( + String bookingId, + boolean present) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AwayDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AwayDto.java new file mode 100644 index 00000000000..606523f3403 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/AwayDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A member saying they will not be in, between two dates, yyyy-MM-dd, inclusive. `reason` +/// is TRAVEL, INJURY or OTHER. +@Mapped +public record AwayDto( + String id, + String startsOn, + String endsOn, + String reason, + boolean freezeBilling) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookRequestDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookRequestDto.java new file mode 100644 index 00000000000..45690388b9c --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookRequestDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Asks for a place in a class for one member of the caller's household. `invitedBy` is the +/// account whose invitation this answers, or empty. +@Mapped +public record BookRequestDto( + String sessionId, + String memberId, + String invitedBy) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingApi.java new file mode 100644 index 00000000000..992d3ee7b16 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingApi.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Places in classes for the caller's household. +/// +/// A class with room gives a place at once; a full one puts the member on its +/// waitlist, and a place given up is handed to the first in the queue, who is +/// told by push. Cancelling inside the class's cut-off counts as a late +/// cancellation and, on a class pack, spends the credit. +@RestClient +public interface BookingApi { + /// The household's places and queue entries from now on, soonest first. + @GET("/api/bookings") + void upcoming(OnComplete>> callback); + + /// The household's classes that have happened, newest first. + @GET("/api/bookings/history") + void history(OnComplete>> callback); + + /// Answers 403 for a member who is not the caller's, 409 when the member + /// is outside the class's age band or already holds a place, and 402 when + /// no membership covers the class. + @POST("/api/bookings") + void book(@Body BookRequestDto request, OnComplete> callback); + + @POST("/api/bookings/{id}/cancel") + void cancel(@Path("id") String id, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingDto.java new file mode 100644 index 00000000000..7bb3e970a14 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/BookingDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A place in a class, or in the queue for one. `position` is 1-based while WAITLISTED and +/// 0 otherwise. +@Mapped +public record BookingDto( + String id, + String sessionId, + String memberId, + String memberName, + String state, + int position, + long createdAt, + long checkedInAt, + SessionDto session) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CampaignDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CampaignDto.java new file mode 100644 index 00000000000..6c79db347f3 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CampaignDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A campaign an owner sent and how it did. `kind` is FILL_CLASS, WIN_BACK or ANNOUNCE. +@Mapped +public record CampaignDto( + String id, + String name, + String kind, + String sessionId, + String title, + String body, + long createdAt, + int sent, + int opened, + int booked) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CardDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CardDto.java new file mode 100644 index 00000000000..a08635a0d8f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CardDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A card as typed into the app. Read by the simulated processor only, which +/// keeps the brand and the last four digits and nothing else; with a real +/// processor the card never reaches the app or this server. +@Mapped +public record CardDto(String number, int expMonth, int expYear, String cvc, String holder) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInRequestDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInRequestDto.java new file mode 100644 index 00000000000..194c1efb29b --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInRequestDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// What the door read: a pass `code` shown on a phone or watch, an `nfcId` tapped, or a +/// `memberId` the desk picked by hand. `locationId` is the door's site. +@Mapped +public record CheckInRequestDto( + String code, + String nfcId, + String memberId, + String locationId) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInResultDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInResultDto.java new file mode 100644 index 00000000000..c358ea6243c --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CheckInResultDto.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Whether the door opens, and why not. `reason` is a key the app translates: ok, unknown, +/// replayed, expired, no-membership, past-due, frozen, age, waiver, no-credits. +@Mapped +public record CheckInResultDto( + boolean admitted, + String reason, + String memberId, + String memberName, + int age, + String planName, + String membershipState, + String sessionName, + long sessionStartsAt, + int visitsThisMonth) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ClassTypeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ClassTypeDto.java new file mode 100644 index 00000000000..87d5c66a2cd --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ClassTypeDto.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A kind of class. `minAge`/`maxAge` are inclusive years; 0 and 120 mean no bound. +@Mapped +public record ClassTypeDto( + String id, + String name, + String description, + String category, + int intensity, + int minAge, + int maxAge, + int capacity, + int minutes, + String color, + boolean active) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CoachStatDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CoachStatDto.java new file mode 100644 index 00000000000..7119d8e7d95 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/CoachStatDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// One trainer's last four weeks. +/// +/// @param fillPercent places booked of places offered in their classes. +/// @param showPercent of those booked, the percent who came. +/// @param kudos kudos members gave for workouts in their classes. +@Mapped +public record CoachStatDto(String account, String name, int classes, int attendees, + int fillPercent, int showPercent, int kudos) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DayAmountDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DayAmountDto.java new file mode 100644 index 00000000000..28c79fb406b --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DayAmountDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// One day of takings: what was charged, and in how many payments. +/// +/// @param date `yyyy-MM-dd`, in UTC. +@Mapped +public record DayAmountDto(String date, long amount, int count) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskApi.java new file mode 100644 index 00000000000..a9a658aff2f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskApi.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The front desk and the door kiosk: for accounts with the DESK role. +@RestClient +public interface DeskApi { + /// Admits a member, or says why not. A refusal is an answer, not an error: + /// the kiosk shows it, and the desk can still let them in by hand. + @POST("/api/desk/checkin") + void checkIn(@Body CheckInRequestDto request, OnComplete> callback); + + /// Members whose name or e-mail contains `q`, at most 25. + @GET("/api/desk/members") + void find(@Query("q") String q, OnComplete>> callback); + + @POST("/api/desk/walkins") + void walkIn(@Body WalkInDto walkIn, OnComplete> callback); + + @GET("/api/desk/today") + void today(@Query("location") String location, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskDayDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskDayDto.java new file mode 100644 index 00000000000..954d0e032c4 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/DeskDayDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The desk's view of today at one site. +@Mapped +public record DeskDayDto( + int checkIns, + int walkIns, + long walkInCents, + List upcoming, + List recent) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/EngagementApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/EngagementApi.java new file mode 100644 index 00000000000..e4683ede03f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/EngagementApi.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The caller's own progress, the times they will be away, and what the studio +/// has sent them. +@RestClient +public interface EngagementApi { + @GET("/api/engagement/stats") + void stats(OnComplete> callback); + + @GET("/api/engagement/away") + void away(OnComplete>> callback); + + /// Declares time away. No nudge is sent meanwhile, and with + /// `freezeBilling` the household's memberships are frozen for it. + @POST("/api/engagement/away") + void addAway(@Body AwayDto away, OnComplete> callback); + + @POST("/api/engagement/away/{id}/remove") + void removeAway(@Path("id") String id, OnComplete> callback); + + @GET("/api/engagement/nudges") + void nudges(OnComplete>> callback); + + /// Marks a nudge read, which is what a campaign's open rate counts. + @POST("/api/engagement/nudges/{id}/opened") + void opened(@Path("id") String id, OnComplete> callback); + + /// Records a workout from the phone's health store or the watch. + @POST("/api/engagement/workouts") + void addWorkout(@Body WorkoutDto workout, OnComplete> callback); + + @GET("/api/engagement/workouts") + void workouts(OnComplete>> callback); + + /// A live heart rate reading during a class, for the trainer's screen. + /// Sent only while the member has chosen to share it. + @POST("/api/engagement/heartrate") + void heartRate(@Body HeartRateDto reading, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FeedItemDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FeedItemDto.java new file mode 100644 index 00000000000..a567007857d --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FeedItemDto.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Something a friend did. `kind` is WORKOUT or BOOKED. +@Mapped +public record FeedItemDto( + String id, + String kind, + String account, + String name, + String title, + long at, + int minutes, + int avgHr, + int calories, + int kudos, + boolean kudoed, + String sessionId) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FlagDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FlagDto.java new file mode 100644 index 00000000000..3bf63533089 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FlagDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Marks an account for a closer look, or clears the mark. +@Mapped +public record FlagDto(boolean flagged, String reason) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FreezeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FreezeDto.java new file mode 100644 index 00000000000..f9ecf9957d1 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FreezeDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Pauses a membership between two dates, yyyy-MM-dd, both inclusive. +@Mapped +public record FreezeDto( + String from, + String until) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendDto.java new file mode 100644 index 00000000000..cd99bc24d53 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Another account the caller trains with. `state` is PENDING_OUT, PENDING_IN or FRIENDS. +@Mapped +public record FriendDto( + String account, + String name, + String state, + long lastVisit, + int weekStreak) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendRequestDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendRequestDto.java new file mode 100644 index 00000000000..bd05427ff49 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/FriendRequestDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Asks someone, by e-mail, to train together. +@Mapped +public record FriendRequestDto( + String email) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HeartRateDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HeartRateDto.java new file mode 100644 index 00000000000..b1c1f800acf --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HeartRateDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A live heart rate reading from a member in a class. +@Mapped +public record HeartRateDto( + String sessionId, + int bpm) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HouseholdApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HouseholdApi.java new file mode 100644 index 00000000000..5d50c060da3 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/HouseholdApi.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The people an account books for: the account holder, and the children +/// they add. A child has no sign-in of their own; everything about them is +/// done by the account that added them. +@RestClient +public interface HouseholdApi { + @GET("/api/household") + void members(OnComplete>> callback); + + @POST("/api/household") + void addChild(@Body NewMemberDto child, OnComplete> callback); + + /// Records that the account holder accepted the studio's waiver for this + /// member. No class can be booked for a member without one. + @POST("/api/household/{id}/waiver") + void signWaiver(@Path("id") String id, OnComplete> callback); + + /// What the member's door pass is made from. Asked once and kept on the + /// device, so the pass works with no signal at the door. + @GET("/api/household/{id}/pass") + void pass(@Path("id") String id, OnComplete> callback); + + /// Replaces the pass secret, for a phone that was lost: every code made from + /// the old one stops opening the door. + @POST("/api/household/{id}/pass/rotate") + void rotatePass(@Path("id") String id, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InsightsDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InsightsDto.java new file mode 100644 index 00000000000..eb65044861e --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InsightsDto.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// How the studio is doing over the last twelve weeks, for the owner's +/// insights screen. Every figure is computed by the server from bookings, +/// check-ins, memberships and invoices; the app only draws it. +/// +/// @param busyHours check-ins of the last eight weeks by weekday and hour, +/// Monday first: `busyHours.get(day * (lastHour - firstHour) + hour - firstHour)`, +/// in the studio's own time zone. +/// @param weeks one point a week, oldest first, the last the current week. +/// @param retention90 of the members who joined 90 to 180 days ago, the +/// percent still running a membership. +/// @param visitsPerMemberTenths average visits a week per active member, in +/// tenths (23 is 2.3). +/// @param revenuePerMemberCents monthly recurring revenue over active members. +@Mapped +public record InsightsDto( + int firstHour, + int lastHour, + List busyHours, + List weeks, + List classes, + List plans, + List coaches, + int retention90, + int visitsPerMemberTenths, + int noShowPercent, + long revenuePerMemberCents) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InviteDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InviteDto.java new file mode 100644 index 00000000000..98f00452df7 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InviteDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Invites a friend to a class. +@Mapped +public record InviteDto( + String sessionId, + String friend) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InvoiceDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InvoiceDto.java new file mode 100644 index 00000000000..0e1dee38d11 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/InvoiceDto.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A bill. `status` is OPEN, PAID, FAILED or VOID; a FAILED one is tried again at +/// `nextAttemptAt` until the ladder runs out. +@Mapped +public record InvoiceDto( + String id, + String memberName, + String description, + long amountCents, + long creditCents, + String currency, + String status, + long periodStart, + long periodEnd, + int attempts, + long nextAttemptAt, + String methodLabel, + String failure, + long createdAt, + long paidAt) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/JoinDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/JoinDto.java new file mode 100644 index 00000000000..27163776d51 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/JoinDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Starts a plan for a member of the household, paid with `methodId` (empty for the default +/// card). +@Mapped +public record JoinDto( + String memberId, + String planId, + String methodId) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/LocationDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/LocationDto.java new file mode 100644 index 00000000000..22e113a5d97 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/LocationDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A site: where it is, and when it is open, in minutes after midnight local time. +@Mapped +public record LocationDto( + String id, + String name, + String address, + String timeZone, + int opensMinute, + int closesMinute) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDetailDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDetailDto.java new file mode 100644 index 00000000000..36b032e1063 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDetailDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// Everything the owner sees about one member. +/// +/// @param visitsByWeek check-ins a week for the last twelve weeks, oldest first. +/// @param favourites the kinds of class they come to most, most first. +/// @param streakWeeks weeks in a row, up to this one, with at least one visit. +@Mapped +public record MemberDetailDto(OwnerMemberDto member, List visitsByWeek, + List favourites, int streakWeeks, long lifetimeCents, + List invoices) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDto.java new file mode 100644 index 00000000000..a43a230fe7c --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A person in the caller's household. `relation` is SELF or CHILD; `age` is in whole years +/// today. +@Mapped +public record MemberDto( + String id, + String name, + String birthDate, + int age, + String relation, + boolean waiverSigned, + String medicalNote, + String membershipState, + String planName) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberSummaryDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberSummaryDto.java new file mode 100644 index 00000000000..fc48cd1eac9 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MemberSummaryDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A member as the desk finds one. +@Mapped +public record MemberSummaryDto( + String id, + String account, + String name, + int age, + String relation, + String membershipState, + String planName, + long lastVisit) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipApi.java new file mode 100644 index 00000000000..76e690ca012 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipApi.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Plans, the household's memberships, and what they were billed. +/// +/// Billing is the server's: a membership renews on the account's default card +/// with nobody present, and a charge the card refuses is tried again on a +/// ladder of days before the membership falls PAST_DUE. Memberships are a +/// service delivered in the studio, not digital content, so nothing here goes +/// through an app store. +@RestClient +public interface MembershipApi { + /// The plans on sale. Each one's age band is checked against the member it + /// is bought for. + @GET("/api/membership/plans") + void plans(OnComplete>> callback); + + @GET("/api/membership") + void memberships(OnComplete>> callback); + + /// Starts a plan and charges its first period now. Answers 402 with the + /// card's reason when the charge is refused; nothing is started then. + @POST("/api/membership") + void join(@Body JoinDto join, OnComplete> callback); + + @POST("/api/membership/{id}/freeze") + void freeze(@Path("id") String id, @Body FreezeDto freeze, + OnComplete> callback); + + @POST("/api/membership/{id}/unfreeze") + void unfreeze(@Path("id") String id, OnComplete> callback); + + /// Ends the membership when the period paid for runs out. Nothing is + /// refunded and nothing more is charged. + @POST("/api/membership/{id}/cancel") + void cancel(@Path("id") String id, OnComplete> callback); + + @GET("/api/membership/invoices") + void invoices(OnComplete>> callback); + + /// Pays an open or failed invoice now, which brings a PAST_DUE membership + /// back to ACTIVE. + @POST("/api/membership/invoices/{id}/pay") + void pay(@Path("id") String id, @Body PayInvoiceDto pay, + OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipDto.java new file mode 100644 index 00000000000..3e8e666fd91 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/MembershipDto.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A member's plan and where it stands. `state` is ACTIVE, FROZEN, PAST_DUE or CANCELLED. +@Mapped +public record MembershipDto( + String id, + String memberId, + String memberName, + String planId, + String planName, + String kind, + String state, + long startedAt, + long currentPeriodEnd, + int creditsLeft, + long frozenFrom, + long frozenUntil, + boolean cancelAtPeriodEnd) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ModerationEventDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ModerationEventDto.java new file mode 100644 index 00000000000..1e61302f975 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ModerationEventDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Something an owner did to an account, and why. +/// +/// @param at Milliseconds since the epoch. +/// @param by The owner who did it. +/// @param action `flag`, `unflag`, `block`, `unblock`, `approve`, `reject`, `roles`. +@Mapped +public record ModerationEventDto(long at, String by, String action, String reason) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NameCountDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NameCountDto.java new file mode 100644 index 00000000000..7440ae3f63b --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NameCountDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A slice of a chart: a name and how many. +@Mapped +public record NameCountDto(String name, int count) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewMemberDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewMemberDto.java new file mode 100644 index 00000000000..b7239b91355 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewMemberDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A child added to the caller's household. `birthDate` is yyyy-MM-dd. +@Mapped +public record NewMemberDto( + String name, + String birthDate, + String medicalNote) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewSessionDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewSessionDto.java new file mode 100644 index 00000000000..f1c3437c620 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NewSessionDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Puts a class on the timetable, once or every week for `weeks` weeks. +@Mapped +public record NewSessionDto( + String classTypeId, + String roomId, + String trainer, + long startsAt, + int capacity, + int weeks) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NudgeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NudgeDto.java new file mode 100644 index 00000000000..cc53d3f3587 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/NudgeDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A message the studio sent. `kind` is FILL_CLASS, WE_MISS_YOU, WAITLIST, INVITE, REFERRAL +/// or CAMPAIGN. +@Mapped +public record NudgeDto( + String id, + String kind, + String sessionId, + String title, + String body, + long sentAt, + long openedAt) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerApi.java new file mode 100644 index 00000000000..d869f61af76 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerApi.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Running the studio: for accounts with the OWNER role. +@RestClient +public interface OwnerApi { + @GET("/api/owner/dashboard") + void dashboard(OnComplete> callback); + + /// Twelve weeks of the studio: busy hours, membership growth and churn, + /// how each kind of class fills, the plan mix and each trainer's month. + @GET("/api/owner/insights") + void insights(OnComplete> callback); + + /// Members with a membership, by name. `filter` narrows the list: AT_RISK, + /// PAST_DUE, FROZEN, NEW (joined in the last 30 days) or CANCELLED; empty + /// for every running membership. + @GET("/api/owner/members") + void members(@Query("filter") String filter, OnComplete>> callback); + + /// One member's visits, favourite classes and bills. + @GET("/api/owner/members/{id}") + void member(@Path("id") String id, OnComplete> callback); + + @GET("/api/owner/plans") + void plans(OnComplete>> callback); + + /// Creates a plan, or changes one; an empty id creates. A changed price + /// applies from each membership's next renewal. + @PUT("/api/owner/plans") + void savePlan(@Body PlanDto plan, OnComplete> callback); + + @PUT("/api/owner/types") + void saveClassType(@Body ClassTypeDto type, OnComplete> callback); + + @GET("/api/owner/rooms") + void rooms(OnComplete>> callback); + + /// Puts a class on the timetable, every week for `weeks` weeks. Answers 409 + /// when the room or the trainer is taken at that time. + @POST("/api/owner/classes") + void schedule(@Body NewSessionDto session, OnComplete>> callback); + + /// Cancels a class. Everyone booked is told by push, and a class pack gets + /// its credit back. + @POST("/api/owner/classes/{id}/cancel") + void cancelClass(@Path("id") String id, @Body ReasonDto reason, + OnComplete> callback); + + /// Gives a class to another trainer. + @POST("/api/owner/classes/{id}/trainer") + void substitute(@Path("id") String id, @Query("trainer") String trainer, + OnComplete> callback); + + @GET("/api/owner/staff") + void staff(OnComplete>> callback); + + @PUT("/api/owner/staff") + void saveStaff(@Body StaffDto staff, OnComplete> callback); + + /// Shifts to come at `location`; an empty one is every location. + @GET("/api/owner/shifts") + void shifts(@Query("location") String location, OnComplete>> callback); + + @POST("/api/owner/shifts") + void addShift(@Body ShiftDto shift, OnComplete> callback); + + @GET("/api/owner/campaigns") + void campaigns(OnComplete>> callback); + + /// Sends a campaign. A FILL_CLASS one goes to members who have taken that + /// kind of class before and are not booked, not away and not in quiet + /// hours; a WIN_BACK one to members who have not been in for three weeks. + @POST("/api/owner/campaigns") + void send(@Body CampaignDto campaign, OnComplete> callback); + + /// Every invoice, newest first, `status` to narrow it (empty for all). + @GET("/api/owner/invoices") + void invoices(@Query("status") String status, OnComplete>> callback); + + /// Voids an invoice nobody should pay. A PAST_DUE membership it held up + /// becomes ACTIVE again. + @POST("/api/owner/invoices/{id}/void") + void voidInvoice(@Path("id") String id, @Body ReasonDto reason, + OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerDashboardDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerDashboardDto.java new file mode 100644 index 00000000000..a2f5dacda76 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerDashboardDto.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The owner's numbers. Money is in cents; `utilisation` is percent of places booked over +/// the last 30 days. +@Mapped +public record OwnerDashboardDto( + int activeMembers, + int newMembers30, + int churned30, + long mrrCents, + long revenue30Cents, + int utilisation, + int checkIns7, + int pastDue, + int atRisk, + List revenueByDay, + List popularClasses, + List underfilled) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerMemberDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerMemberDto.java new file mode 100644 index 00000000000..916b87e5d1d --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/OwnerMemberDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A member as the owner's member list shows them. +/// +/// @param state the membership's state: ACTIVE, FROZEN, PAST_DUE or CANCELLED. +/// @param atRisk not in for two weeks and not away. +@Mapped +public record OwnerMemberDto(String id, String account, String name, String planName, + String state, long joinedAt, long lastVisit, int visits30, boolean atRisk, + boolean away) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PassDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PassDto.java new file mode 100644 index 00000000000..61eedd0c604 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PassDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// What a member's pass is made from. The app derives a fresh code from `secret` every +/// `periodSeconds` with no network; `nfcId` is what a tap at the door sends. +@Mapped +public record PassDto( + String memberId, + String memberName, + String secret, + String nfcId, + int periodSeconds, + int digits) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PasswordChangeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PasswordChangeDto.java new file mode 100644 index 00000000000..46a025bbce7 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PasswordChangeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A change of password: the one in use, and the one to use from now on. +@Mapped +public record PasswordChangeDto(String current, String replacement) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PayInvoiceDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PayInvoiceDto.java new file mode 100644 index 00000000000..cc2c7306b94 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PayInvoiceDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Pays an invoice now, with `methodId` or the default card when empty. +@Mapped +public record PayInvoiceDto( + String methodId) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentApi.java new file mode 100644 index 00000000000..4e81ffa0c17 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentApi.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Ways to pay, and what was paid. Everything here is the signed-in user's own. +@RestClient +public interface PaymentApi { + @GET("/api/payments/config") + void config(OnComplete> callback); + + /// The saved cards, and cash, which is always there. + @GET("/api/payments/methods") + void methods(OnComplete>> callback); + + /// Begins adding a card; see [PaymentSetupDto] for the two ways it goes on. + @POST("/api/payments/methods/setup") + void startSetup(OnComplete> callback); + + /// Finishes adding a card. `card` is read by the simulated processor and + /// ignored when the setup was hosted. + @POST("/api/payments/methods/setup/{id}/complete") + void completeSetup(@Path("id") String id, @Body CardDto card, + OnComplete> callback); + + @POST("/api/payments/methods/{id}/default") + void makeDefault(@Path("id") String id, OnComplete> callback); + + @POST("/api/payments/methods/{id}/remove") + void remove(@Path("id") String id, OnComplete> callback); + + /// The caller's receipts as a customer, newest first. +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentConfigDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentConfigDto.java new file mode 100644 index 00000000000..81179b869a6 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentConfigDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// How this server takes payments, so the app knows which way to add a card. +/// +/// @param provider `simulated` or `stripe`. +@Mapped +public record PaymentConfigDto(String provider, String currency) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentMethodDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentMethodDto.java new file mode 100644 index 00000000000..d5d1aad7242 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentMethodDto.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A way to pay. Cash is always one of them, under the id `cash`. +/// +/// @param kind `card` or `cash`. +/// @param label The method as a person would name it: "Visa 4242", "Cash". +@Mapped +public record PaymentMethodDto( + String id, + String kind, + String brand, + String last4, + int expMonth, + int expYear, + boolean isDefault, + String label) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentSetupDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentSetupDto.java new file mode 100644 index 00000000000..c0abbccba82 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PaymentSetupDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// An attempt to add a card, begun. +/// +/// When `hosted` is true the card is typed into the payment processor's own page +/// at `url`: open it in the browser, and call complete when the user returns. +/// Otherwise the app collects a [CardDto] itself and sends it with complete. +@Mapped +public record PaymentSetupDto(String id, boolean hosted, String url) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneChallengeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneChallengeDto.java new file mode 100644 index 00000000000..6a62ebd92c2 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneChallengeDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The answer to asking for a verification code: how long the code that was just +/// sent stays good. +/// +/// @param demoCode The code itself, but only from a server that has no way to send a text message +/// and is running on a development profile. It lets the sample be tried end to end without an +/// SMS account; a server configured with a real sender leaves it null. +@Mapped +public record PhoneChallengeDto(int expiresInSeconds, String demoCode) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneCodeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneCodeDto.java new file mode 100644 index 00000000000..bb4691a6ed7 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PhoneCodeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The code the user typed in. +@Mapped +public record PhoneCodeDto(String code) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanDto.java new file mode 100644 index 00000000000..920e26c8e17 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanDto.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Something that can be bought. `kind` is MONTHLY, ANNUAL, CLASS_PACK or DROP_IN. +@Mapped +public record PlanDto( + String id, + String name, + String description, + String kind, + long priceCents, + String currency, + int periodMonths, + int classCredits, + int minAge, + int maxAge, + boolean offPeakOnly, + boolean active) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanMixDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanMixDto.java new file mode 100644 index 00000000000..23a8de94692 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PlanMixDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The members on one plan, and what they bring in a month. +@Mapped +public record PlanMixDto(String name, String kind, int members, long monthlyCents) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PreferencesDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PreferencesDto.java new file mode 100644 index 00000000000..024b9ec70f0 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PreferencesDto.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// How one person wants the app and its reminders. Kept on the server, so it +/// follows the account to another phone. +/// +/// @param language `en`, `es`, `fr`, `de` or `he`. +/// @param theme `system`, `light` or `dark`. +/// @param units `km` or `mi`, for distances run. +/// @param notifyClasses Reminders before a booked class, and a place opening up from a waitlist. +/// @param notifyPromotions Nudges: a class with room in it, a friend's invitation, a campaign. +/// @param defaultPaymentMethodId A saved card's id; memberships renew on it. +/// @param quietFrom Hour of the day, 0 to 23, from which no nudge is sent. +/// @param quietTo Hour at which nudges may start again. Equal to `quietFrom` means no quiet hours. +@Mapped +public record PreferencesDto( + String language, + String theme, + String units, + boolean notifyClasses, + boolean notifyReceipts, + boolean notifyPromotions, + String defaultPaymentMethodId, + int quietFrom, + int quietTo) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ProfileDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ProfileDto.java new file mode 100644 index 00000000000..f64b0de1b80 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ProfileDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The parts of an account its owner edits. +/// +@Mapped +public record ProfileDto( + String name) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PushDeviceDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PushDeviceDto.java new file mode 100644 index 00000000000..f884c905049 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/PushDeviceDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A device that can be sent push notifications, as the app registers it. +/// +/// `provider` is the push service the device's token belongs to -- `fcm`, +/// `apns`, `huawei`, `wns` or `web` -- and `token` the token that service gave +/// the app. `platform` is the device's platform name, for the record only. +@Mapped +public record PushDeviceDto(String provider, String token, String platform) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ReasonDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ReasonDto.java new file mode 100644 index 00000000000..be3a9e5cd77 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ReasonDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Why an owner did something: cancelled a class, voided an invoice, +/// suspended an account. +@Mapped +public record ReasonDto(String reason) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RegisterDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RegisterDto.java new file mode 100644 index 00000000000..5d4862a5e7d --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RegisterDto.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// What opening an account takes. The password travels once, in the body of a +/// request over TLS, and the server keeps only its hash. +/// +/// @param birthDate `yyyy-MM-dd`: the account holder is a member too, and some classes +/// and plans have an age band. +/// @param referredBy The e-mail of the member who sent them, or empty. Both are credited +/// once the new account pays its first invoice. +@Mapped +public record RegisterDto( + String email, + String password, + String displayName, + String phone, + String birthDate, + String referredBy) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoleChangeDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoleChangeDto.java new file mode 100644 index 00000000000..e640d400b6a --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoleChangeDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// The staff roles an owner gives an account, or takes away. Every account is +/// a member whatever is set here. +@Mapped +public record RoleChangeDto(boolean trainer, boolean desk, boolean owner) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoomDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoomDto.java new file mode 100644 index 00000000000..ea758673616 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RoomDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A room at a site. +@Mapped +public record RoomDto( + String id, + String locationId, + String name, + int capacity) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RosterEntryDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RosterEntryDto.java new file mode 100644 index 00000000000..c1dc3209022 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/RosterEntryDto.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// One person on a trainer's class list. `state` is BOOKED, CHECKED_IN, NO_SHOW or +/// WAITLISTED; `heartRate` is the last live reading, 0 for none. +@Mapped +public record RosterEntryDto( + String bookingId, + String memberId, + String name, + int age, + String state, + int position, + boolean firstVisit, + String medicalNote, + int heartRate, + int zone) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SessionDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SessionDto.java new file mode 100644 index 00000000000..72089eca0f9 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SessionDto.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// One class on the timetable, with how full it is and, for the caller, which of their +/// household hold a place in it (`booked`) or are queued for one (`waitlisted`): member +/// ids. +@Mapped +public record SessionDto( + String id, + String classTypeId, + String name, + String category, + int intensity, + int minAge, + int maxAge, + String color, + String locationId, + String locationName, + String roomName, + String trainer, + String trainerName, + long startsAt, + long endsAt, + int capacity, + int booked, + int waitlisted, + String state, + String note, + List mine, + List queued, + int friendsGoing) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ShiftDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ShiftDto.java new file mode 100644 index 00000000000..3572217a274 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/ShiftDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A block of work. `state` is ASSIGNED, SWAP_REQUESTED or OPEN. +@Mapped +public record ShiftDto( + String id, + String locationId, + String staff, + String staffName, + String duty, + long startsAt, + long endsAt, + String state) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SocialApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SocialApi.java new file mode 100644 index 00000000000..494647adb83 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SocialApi.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Training with friends. Nothing is shown to anyone who is not a friend, and a +/// workout reaches the feed only when its owner shared it. +@RestClient +public interface SocialApi { + @GET("/api/social/friends") + void friends(OnComplete>> callback); + + /// Asks a member, by e-mail, to be friends. Answers the same whether or not + /// the address has an account, so the call cannot be used to find out. + @POST("/api/social/friends") + void request(@Body FriendRequestDto request, OnComplete> callback); + + @POST("/api/social/friends/{account}/accept") + void accept(@Path("account") String account, OnComplete> callback); + + @POST("/api/social/friends/{account}/remove") + void remove(@Path("account") String account, OnComplete> callback); + + @GET("/api/social/feed") + void feed(OnComplete>> callback); + + @POST("/api/social/workouts/{id}/kudos") + void kudos(@Path("id") String id, OnComplete> callback); + + /// Invites a friend to a class; they are sent a push they can book from. + @POST("/api/social/invite") + void invite(@Body InviteDto invite, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StaffDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StaffDto.java new file mode 100644 index 00000000000..9fbf430d86f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StaffDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// Someone who works here. +@Mapped +public record StaffDto( + String username, + String name, + String title, + String bio, + String locationId, + long certifiedUntil, + List roles, + int classesThisWeek) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StatsDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StatsDto.java new file mode 100644 index 00000000000..bc202486860 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StatsDto.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// How a member is doing: visits, the current weekly streak, minutes trained, +/// and the points, level and badges they earned for it. +/// +/// `levelFloor` and `nextLevel` are the points the current level starts at and +/// the next one does, for drawing progress between them; `nextLevel` equals +/// `levelFloor` at the top level. `badges` are the ids of the badges earned, +/// in the order they are listed in `Points.BADGES` on the server. +@Mapped +public record StatsDto( + int visitsThisMonth, + int visitsTotal, + int weekStreak, + int minutesThisMonth, + int caloriesThisMonth, + long lastVisit, + List weeklyVisits, + int points, + int level, + int levelFloor, + int nextLevel, + List badges) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StudioApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StudioApi.java new file mode 100644 index 00000000000..e78744ee27c --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/StudioApi.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The studio as anyone signed in sees it: its sites, its classes and its +/// trainers. +@RestClient +public interface StudioApi { + @GET("/api/studio/locations") + void locations(OnComplete>> callback); + + @GET("/api/studio/types") + void classTypes(OnComplete>> callback); + + @GET("/api/studio/trainers") + void trainers(OnComplete>> callback); + + /// The timetable at a site from `from` (epoch millis) for `days` days, at + /// most 14, cancelled classes included so a member sees what changed. + @GET("/api/studio/classes") + void classes(@Query("location") String location, @Query("from") long from, + @Query("days") int days, OnComplete>> callback); + + @GET("/api/studio/classes/{id}") + void session(@Path("id") String id, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SuspendDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SuspendDto.java new file mode 100644 index 00000000000..7bbf098fd3f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/SuspendDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Blocks an account, or lifts the block. +/// +/// @param reason Why, for the moderation record. May be empty. +@Mapped +public record SuspendDto(boolean suspended, String reason) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TicketDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TicketDto.java new file mode 100644 index 00000000000..8d3f135366f --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TicketDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A single-use ticket for opening the live channel. +/// +/// A WebSocket handshake cannot carry the bearer token on every platform, so the +/// app asks for a ticket over the authenticated API and presents it in the +/// connection address. It is good for one connection and for a few seconds. +@Mapped +public record TicketDto(String ticket, int expiresInSeconds) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerApi.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerApi.java new file mode 100644 index 00000000000..826c71a7c2e --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerApi.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// A trainer's classes, rosters and shifts: for accounts with the TRAINER role. +@RestClient +public interface TrainerApi { + /// The trainer's classes for the next 14 days. + @GET("/api/trainer/classes") + void classes(OnComplete>> callback); + + /// Who is coming, with the live heart rate of anyone sharing one. + @GET("/api/trainer/classes/{id}/roster") + void roster(@Path("id") String id, OnComplete>> callback); + + @POST("/api/trainer/classes/{id}/attendance") + void attendance(@Path("id") String id, @Body AttendanceDto mark, + OnComplete> callback); + + /// The trainer's own shifts, and open ones at their site anyone may take. + /// The signed-in trainer's last four weeks: classes, attendance, regulars + /// and kudos. + @GET("/api/trainer/stats") + void stats(OnComplete> callback); + + @GET("/api/trainer/shifts") + void shifts(OnComplete>> callback); + + /// Offers one of the trainer's shifts to the others. + @POST("/api/trainer/shifts/{id}/swap") + void offer(@Path("id") String id, OnComplete> callback); + + /// Takes a shift another trainer offered. + @POST("/api/trainer/shifts/{id}/take") + void take(@Path("id") String id, OnComplete> callback); +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerDto.java new file mode 100644 index 00000000000..4fb85032684 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// A trainer as members see one. +@Mapped +public record TrainerDto( + String username, + String name, + String title, + String bio, + String locationId) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerStatsDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerStatsDto.java new file mode 100644 index 00000000000..6872589f191 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TrainerStatsDto.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; +import java.util.List; + +/// A trainer's own numbers over the last four weeks, and the game made of them. +/// +/// @param regulars members who came to three or more of their classes. +/// @param attendeesByWeek people who came, a week at a time for eight weeks, +/// oldest first. +/// @param topClasses their kinds of class by attendance, most first. +/// @param points what the last eight weeks earned: 10 a check-in, 5 a kudos and +/// 25 a full class. +/// @param level the level those points reach; `levelFloor` and `nextLevel` are +/// the points it starts at and the next one starts at. +/// @param weekStreak weeks running, back from this one, with a class taught. +/// @param fullHouses classes in the four weeks with every place booked. +/// @param rank where the trainer stands among the coaches by check-ins in the +/// four weeks, 1 the most; 0 when they taught nothing. +/// @param badges the coach badges earned, by id. +/// @param leaderboard every coach who taught in the four weeks with their +/// check-ins, most first. +@Mapped +public record TrainerStatsDto(int classes, int attendees, int fillPercent, int showPercent, + int regulars, int kudos, List attendeesByWeek, List topClasses, + int points, int level, int levelFloor, int nextLevel, int weekStreak, int fullHouses, + int rank, List badges, List leaderboard) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TypeFillDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TypeFillDto.java new file mode 100644 index 00000000000..5a9bb695212 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/TypeFillDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// How well one kind of class fills, over the last four weeks. +/// +/// @param fillPercent places booked of places offered. +/// @param noShowPercent bookings nobody turned up for. +@Mapped +public record TypeFillDto(String name, String color, int classes, int fillPercent, + int noShowPercent) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDetailDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDetailDto.java new file mode 100644 index 00000000000..097d7ca5416 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDetailDto.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import java.util.List; + +import com.codename1.annotations.Mapped; + +/// One account in full, for the owner deciding what to do about it. +/// +/// @param household The people the account books for, itself first. +/// @param visits Check-ins of the household, ever. +/// @param noShows Places booked and not taken, ever. +/// @param spent Cents paid, ever. +@Mapped +public record UserDetailDto( + UserDto user, + List household, + int visits, + int noShows, + long spent, + List events) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDto.java new file mode 100644 index 00000000000..ca3b5360998 --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/UserDto.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Who is signed in, as the app needs to know it: enough to greet them, to +/// decide which modes to offer, and to tell whether they still owe a phone +/// verification. +/// +/// @param username The sign-in name, which is the e-mail address the account was opened with. +/// @param member Every account is a member, and books for its household. +/// @param trainer Granted: teaches classes and sees their rosters. +/// @param desk Granted: works the front desk -- checks people in and takes walk-ins. +/// @param owner Granted: runs the studio -- plans, timetable, staff, campaigns, numbers. +/// @param suspended Blocked by an owner: the account cannot sign in or call the API. +/// @param flagged Marked by an owner for a closer look. The account still works; `suspended` is +/// what stops one. +/// @param flagReason Why it was flagged, for the other owners. +/// @param language The language the app is shown in: `en`, `es`, `fr`, `de` or `he`. +@Mapped +public record UserDto( + String username, + String displayName, + String phone, + boolean phoneVerified, + boolean member, + boolean trainer, + boolean desk, + boolean owner, + boolean suspended, + boolean flagged, + String flagReason, + String language) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WalkInDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WalkInDto.java new file mode 100644 index 00000000000..0f1bcd1a4dd --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WalkInDto.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// Someone at the desk without a membership. `kind` is GUEST, DAY_PASS or TRIAL; `paidBy` +/// is cash, card or none. +@Mapped +public record WalkInDto( + String id, + String locationId, + String name, + String email, + String phone, + String kind, + long amountCents, + String paidBy, + long at) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WeekPointDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WeekPointDto.java new file mode 100644 index 00000000000..c8e1a0184ed --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WeekPointDto.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +/// One week of the studio's membership. +/// +/// @param weekOf the Monday that starts the week, `yyyy-MM-dd`. +/// @param active memberships running at the end of the week. +/// @param joined memberships started in the week. +/// @param left memberships cancelled in the week. +/// @param visits check-ins in the week. +@Mapped +public record WeekPointDto(String weekOf, int active, int joined, int left, int visits) { +} diff --git a/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WorkoutDto.java b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WorkoutDto.java new file mode 100644 index 00000000000..0dfcf68896e --- /dev/null +++ b/scripts/pulse/shared/src/main/java/com/codenameone/examples/pulse/api/WorkoutDto.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.pulse.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A workout the phone's health store or the watch recorded. `zones` is minutes in each of +/// the five heart rate zones. +@Mapped +public record WorkoutDto( + String id, + String sessionId, + String activity, + long startedAt, + int minutes, + int avgHr, + int maxHr, + int calories, + List zones, + String source, + boolean shared, + int points) { +} diff --git a/scripts/pulse/web-e2e.mjs b/scripts/pulse/web-e2e.mjs new file mode 100644 index 00000000000..5c2d7d45f24 --- /dev/null +++ b/scripts/pulse/web-e2e.mjs @@ -0,0 +1,328 @@ +// The Pulse web app, hosted by its own server, driven in a headless browser. +// +// URL=http://localhost:18081/ OUT=target/web-e2e node web-e2e.mjs +// +// It checks what a person opening the server's address gets: the app loads from +// the server, reaches the welcome screen, signs in as the demo customer, lands on +// the home screen with its map, and has its live channel open. Then it signs +// out, signs in as the demo admin and reaches the admin console. +// run-web-e2e.sh builds everything, starts the server and runs this; see there. +// +// The app draws on a canvas, so there are no buttons in the page to ask a +// browser automation tool for. What there is, is the accessibility tree the +// port keeps over the canvas -- an element per component, with its role, its +// label and where it is -- and that is what this reads to find a button and +// what it clicks at. Text fields become real elements while they are +// being edited, so typing is ordinary typing. +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// require() and not import: it also looks in NODE_PATH, which is how a +// container whose packages are installed somewhere else finds Playwright. +const require = createRequire(import.meta.url); +let chromium; +try { + ({ chromium } = require('playwright')); +} catch (err) { + console.error('[pulse-web] Playwright is not installed. Run `npm ci` in scripts/ and ' + + '`npx playwright install chromium`, or point NODE_PATH at a node_modules that has it.'); + console.error(String(err)); + process.exit(2); +} + +const url = process.env.URL; +const out = process.env.OUT || 'target/web-e2e'; +const email = process.env.PULSE_USER || 'member@pulse.example'; +const password = process.env.PULSE_PASSWORD || 'pulse-demo'; +const admin = process.env.PULSE_ADMIN || 'owner@pulse.example'; +// A cold runner translates nothing at this point, but it does parse several +// megabytes of JavaScript before the first screen. +const startSeconds = Number(process.env.PULSE_WEB_START_SECONDS || '120'); +const stepSeconds = Number(process.env.PULSE_WEB_STEP_SECONDS || '45'); +if (!url) { + console.error('[pulse-web] URL is required'); + process.exit(2); +} +fs.mkdirSync(out, { recursive: true }); +const logFile = path.join(out, 'browser.log'); +fs.writeFileSync(logFile, ''); +const note = line => fs.appendFileSync(logFile, line + '\n'); +const say = line => { console.log('[pulse-web] ' + line); note('# ' + line); }; + +const origin = new URL(url).origin; +const requests = []; // "200 POST /login", in order +const problems = []; // anything that must not happen +const sockets = []; // { url, frames: [...] } + +const browser = await chromium.launch({ + headless: true, + // A headless page counts as hidden, and a hidden page's timers are throttled + // to about one a minute once its budget is spent. The app's threads sleep on + // those timers. + args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', '--disable-features=IntensiveWakeUpThrottling'] +}); +const page = await browser.newPage({ viewport: { width: 420, height: 860 } }); +page.on('console', message => { + const text = message.text(); + if (text.startsWith('LF-SHIM')) { + return; + } + note(`[${message.type()}] ${text}`); + if (/internal application error|Missing JS member/i.test(text)) { + problems.push('the app reported an error: ' + text.split('\n')[0]); + } +}); +page.on('pageerror', err => { + note('[pageerror] ' + err); + problems.push('the page threw: ' + err); +}); +page.on('response', response => { + const at = new URL(response.url()); + if (at.origin !== origin) { + return; + } + const line = `${response.status()} ${response.request().method()} ${at.pathname}`; + requests.push(line); + // The query says where a list was asked for; it holds no secrets (a token + // travels in a header). + note('NET ' + line + (at.pathname.startsWith('/api/') ? at.search : '')); +}); +page.on('websocket', socket => { + const seen = { url: socket.url(), frames: [] }; + sockets.push(seen); + note('WS open ' + socket.url().replace(/ticket=.*/, 'ticket=...')); + socket.on('framereceived', frame => { + seen.frames.push(String(frame.payload)); + note('WS frame ' + String(frame.payload).slice(0, 200)); + }); +}); + +/// What the app has on screen, as the port describes it to assistive technology. +async function components() { + return page.evaluate(() => Array.from( + document.querySelectorAll('#cn1-accessibility-tree [data-cn1-accessibility-id]')).map(el => { + const box = el.getBoundingClientRect(); + return { + role: el.getAttribute('role'), + label: el.getAttribute('aria-label'), + text: el.innerText, + x: box.x, y: box.y, width: box.width, height: box.height + }; + })); +} + +async function screenText() { + return page.evaluate(() => document.body.innerText); +} + +async function waitFor(what, seconds, probe) { + const end = Date.now() + seconds * 1000; + for (;;) { + const hit = await probe(); + if (hit) { + return hit; + } + if (problems.length > 0) { + throw new Error(problems[0]); + } + if (Date.now() > end) { + throw new Error(`timed out after ${seconds}s waiting for ${what}`); + } + await page.waitForTimeout(250); + } +} + +const button = label => async () => + (await components()).find(c => c.role === 'button' && c.label === label); + +/// The single-line fields of the form on screen, top to bottom. +async function fields() { + return (await components()).filter(c => c.role === 'textbox' && c.height > 40 && c.height < 120) + .sort((a, b) => a.y - b.y); +} + +async function click(component) { + await page.mouse.click(component.x + component.width / 2, component.y + component.height / 2); +} + +/// Waits until nothing on screen has moved for a moment. A form slides in, and +/// a click aimed at where a field is half-way through lands on what was there. +async function settled() { + let before = JSON.stringify(await components()); + await waitFor('the screen to stop moving', stepSeconds, async () => { + await page.waitForTimeout(350); + const now = JSON.stringify(await components()); + const still = now === before; + before = now; + return still; + }); +} + +async function typeInto(index, text, type) { + const editing = () => page.evaluate( + wanted => document.activeElement && document.activeElement.tagName === 'INPUT' + && document.activeElement.type === wanted, type); + await waitFor(`the ${type} field to take the keyboard`, stepSeconds, async () => { + await settled(); + const all = await fields(); + if (all.length <= index) { + return false; + } + await click(all[index]); + await page.waitForTimeout(500); + return editing(); + }); + await page.keyboard.type(text, { delay: 10 }); +} + +/// Fills in the sign-in form the welcome screen opens, and sends it. +async function signInAs(who) { + await click(await waitFor('the welcome screen', startSeconds, button('Sign in'))); + await typeInto(0, who, 'email'); + await typeInto(1, password, 'password'); + // The form's own button now: the welcome screen's is gone with its form. + await settled(); + await click(await waitFor('the form\'s Sign in button', stepSeconds, async () => + (await fields()).length >= 2 ? button('Sign in')() : null)); + say('submitted the sign-in form as ' + who); +} + +/// Fails on the dialog the app shows for an error nothing handled. +async function noErrorDialog() { + if (/internal application error/i.test(await screenText())) { + problems.push('the app is showing its internal-error dialog'); + } + if (problems.length > 0) { + throw new Error(problems[0]); + } +} + +let failed = null; +try { + say('opening ' + url); + const first = await page.goto(url, { waitUntil: 'load' }); + if (!first || first.status() !== 200) { + throw new Error('the server answered ' + (first ? first.status() : 'nothing') + ' at /'); + } + const type = first.headers()['content-type'] || ''; + if (!type.startsWith('text/html')) { + throw new Error('/ is served as ' + type + ', not as a page'); + } + + // 1. The welcome screen: the app started, in the browser, from this server. + const signIn = await waitFor('the welcome screen', startSeconds, button('Sign in')); + await waitFor('the welcome screen to offer an account', stepSeconds, button('Create an account')); + await page.screenshot({ path: path.join(out, '1-welcome.png') }); + say('the welcome screen is up'); + for (const file of ['/translated_app.js', '/port.js', '/worker.js']) { + if (!requests.includes('200 GET ' + file)) { + throw new Error(file + ' was not served by this server: ' + requests.join(', ')); + } + } + + // 2. Signing in. The form is the app's own; the flow behind it is the + // authorization-code flow against the server the page came from. + await signInAs(email); + + // The first sign-in in a browser is asked whether usage statistics may be + // shared. The answer is kept with the page's storage, so the admin's + // sign-in further down is not asked again. + await click(await waitFor('the usage statistics question', stepSeconds, button('Not now'))); + say('declined usage statistics'); + + // 3. The home screen. + // The member's home: the next class, read from the server, and the + // member's level. The eyebrow is set in capitals, so compare without case. + await waitFor('the home screen', stepSeconds, async () => { + const text = (await screenText()).toLowerCase(); + return text.includes('next class') && text.includes('level'); + }); + await waitFor('the home screen to ask for the timetable', stepSeconds, async () => + requests.some(r => /^200 GET \/api\/(studio\/classes|bookings)/.test(r))); + await page.waitForTimeout(1500); + await page.screenshot({ path: path.join(out, '3-home.png') }); + say('signed in: the home screen is up'); + + const expected = ['200 POST /login', '200 GET /signin/code', '200 POST /oauth2/token', + '200 GET /api/me']; + for (const line of expected) { + if (!requests.includes(line)) { + throw new Error('signing in did not make the request "' + line + '"; it made: ' + + requests.filter(r => !/\.(js|css|png|res|gif|json|ttf)$/.test(r)).join(', ')); + } + } + + // 4. The live channel, a WebSocket to the same origin. + const live = await waitFor('the live channel', stepSeconds, async () => + sockets.find(s => s.frames.some(f => f.includes('"ready"')))); + const expectedSocket = origin.replace(/^http/, 'ws') + '/ws/live?ticket='; + if (!live.url.startsWith(expectedSocket)) { + throw new Error('the live channel went to ' + live.url + ', not to ' + expectedSocket); + } + say('the live channel is open'); + + // Give a late failure -- the first location request, the first map tile -- + // the moment it needs to show itself. + await page.waitForTimeout(3000); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '4-settled.png') }); + + // 5. The session that carried the sign-in is over: the app ended it when it + // had its tokens, so the cookie the browser still holds is worth nothing. + await waitFor('the app to end its sign-in session', stepSeconds, async () => + requests.includes('204 POST /logout')); + const again = await page.evaluate(async () => (await fetch('/oauth2/authorize?response_type=code' + + '&client_id=pulse-app&scope=openid&state=x&code_challenge_method=S256' + + '&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + + '&redirect_uri=' + encodeURIComponent(location.origin + '/signin/code'), + { headers: { Accept: 'application/json' } })).status); + if (again === 200) { + throw new Error('the browser can still get an authorization code after signing in'); + } + say('the sign-in session is over (asking for another code is answered ' + again + ')'); + + // 6. Signing out, from the menu. Its button is an icon with no words: the + // one button at the top of the screen. + await settled(); + const menu = await waitFor('the menu button', stepSeconds, async () => + (await components()).filter(c => c.role === 'button' && c.y < 140 && c.width < 120 + && c.width > 20).sort((a, b) => a.x - b.x)[0]); + await click(menu); + await settled(); + await click(await waitFor('Sign out in the menu', stepSeconds, button('Sign out'))); + await waitFor('the welcome screen after signing out', stepSeconds, button('Create an account')); + await settled(); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '5-signed-out.png') }); + say('signed out: the welcome screen is back'); + + // 7. The admin, who lands in the console. + await signInAs(admin); + await waitFor('the owner dashboard to ask the server for its figures', stepSeconds, + async () => requests.some(r => /^200 GET \/api\/owner\/dashboard/.test(r))); + await page.waitForTimeout(3000); + await settled(); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '6-owner.png') }); + say('signed in as ' + admin + ': the owner dashboard is up'); +} catch (err) { + failed = err; + try { + await page.screenshot({ path: path.join(out, 'failure.png') }); + fs.writeFileSync(path.join(out, 'failure.txt'), await screenText()); + fs.writeFileSync(path.join(out, 'failure-components.json'), + JSON.stringify(await components(), null, 1)); + } catch (ignored) { + // The page is gone; the log is what there is. + } +} +await browser.close(); + +if (failed) { + console.error('[pulse-web] FAILED: ' + failed.message); + console.error('[pulse-web] the browser log is ' + logFile); + process.exit(1); +} +say('passed'); diff --git a/scripts/sync-initializr-template.py b/scripts/sync-initializr-template.py index 89e22cb175a..957c0b7c5e4 100644 --- a/scripts/sync-initializr-template.py +++ b/scripts/sync-initializr-template.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Keep an Initializr full-stack template aligned with its sample (--write to update). - python3 scripts/sync-initializr-template.py wayline|savor|skirmish|parley|bastion|reel [--write] + python3 scripts/sync-initializr-template.py wayline|savor|skirmish|parley|bastion|reel|pulse [--write] scripts/ is the project CI builds and tests. The Initializr offers the same project as a template, from five resources this script derives from it: @@ -48,7 +48,7 @@ root = Path(__file__).resolve().parents[1] # The samples the Initializr offers as full-stack templates. Each is a project # under scripts/ whose Java package is com.codenameone.examples.. -SAMPLES = ('wayline', 'savor', 'skirmish', 'parley', 'bastion', 'reel') +SAMPLES = ('wayline', 'savor', 'skirmish', 'parley', 'bastion', 'reel', 'pulse') args = [a for a in sys.argv[1:] if not a.startswith('--')] if len(args) != 1 or args[0] not in SAMPLES: raise SystemExit('usage: sync-initializr-template.py ' + '|'.join(SAMPLES) + ' [--write]') diff --git a/scripts/tests/GenerateInitializr.java b/scripts/tests/GenerateInitializr.java index 62b0b65adb9..5d572bf0511 100644 --- a/scripts/tests/GenerateInitializr.java +++ b/scripts/tests/GenerateInitializr.java @@ -90,7 +90,7 @@ public static void main(String[] args) throws Exception { } // The full-stack templates, each in both of its layouts and in a colour scheme of // its own with an icon: an app, its server and the module they share. - for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, Template.BASTION, Template.REEL}) { + for (Template template : new Template[] {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, Template.BASTION, Template.REEL, Template.PULSE}) { for (boolean full : new boolean[] {false, true}) { ProjectOptions options = ProjectOptions.defaults().forFullStack().withPlatformModules(full) .withScheme(0x0f766e, false).withIcon(new byte[] {(byte)0x89, 'P', 'N', 'G'}); diff --git a/scripts/tests/generate-initializr-fixtures.py b/scripts/tests/generate-initializr-fixtures.py index e42680ac9a0..7938b4ed517 100644 --- a/scripts/tests/generate-initializr-fixtures.py +++ b/scripts/tests/generate-initializr-fixtures.py @@ -89,6 +89,7 @@ def check_maven_layout_fixture(archive, z): 'PARLEY': ('api/ConversationApi.java', 'ui/CallScreen.java'), 'REEL': ('api/CatalogApi.java', 'ui/WelcomeForm.java'), 'BASTION': ('api/BankingApi.java', 'ui/HomeForm.java'), + 'PULSE': ('api/BookingApi.java', 'ui/WelcomeForm.java'), } @@ -100,6 +101,7 @@ def check_maven_layout_fixture(archive, z): 'PARLEY': 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=', 'BASTION': 'codename1.arg.ios.detectJailbreak=true', 'REEL': None, + 'PULSE': 'codename1.arg.ios.NSHealthShareUsageDescription=', } @@ -139,7 +141,7 @@ def check_full_stack_fixture(archive, z, template): assert ('javase/pom.xml' in names) == full, archive.name for name in ['shared/src/main/java/com/example/probe/' + contract, 'backend/src/main/java/com/example/probe/SecurityConfig.java', - 'backend/src/main/resources/db/migration/' + ('V1__game.sql' if rules else 'V4__accounts.sql' if template == 'BASTION' else 'V1__accounts.sql'), + 'backend/src/main/resources/db/migration/' + ('V1__game.sql' if rules else 'V4__accounts.sql' if template == 'BASTION' else 'V2__accounts.sql' if template == 'PULSE' else 'V1__accounts.sql'), 'backend/server.sh', 'common/src/main/java/com/example/probe/LauncherProbe.java', 'common/src/main/java/com/example/probe/' + screen]: