diff --git a/.github/workflows/soak_test.yml b/.github/workflows/soak_test.yml new file mode 100644 index 0000000..dee221e --- /dev/null +++ b/.github/workflows/soak_test.yml @@ -0,0 +1,78 @@ +name: Soak Test + +on: + workflow_dispatch: + push: + branches: [ main ] + +jobs: + run-soak-test: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Checkout folder demo-apps/PetClinic-Android from test/e2e + run: | + git fetch origin test/e2e + git checkout origin/test/e2e -- demo-apps/PetClinic-Android + + - name: Build Android SDK + run: | + ./gradlew build -x :demo-apps:agent-demo:build -x :demo-apps:anr-demo:build -x :demo-apps:crash-demo:build + + - name: Publish SDK to Maven Local (not a release) + run: | + unset CI && ./gradlew publishToMavenLocal + + - name: Build Android App + run: | + cd demo-apps/PetClinic-Android + ./gradlew assembleDebug assembleDebugAndroidTest + + - name: Build Artifacts - App APK + uses: actions/upload-artifact@v4 + with: + name: petclinic-android-app + path: demo-apps/PetClinic-Android/build/outputs/apk/debug/PetClinic-debug.apk + retention-days: 7 + + - name: Build Artifacts - Test APK + uses: actions/upload-artifact@v4 + with: + name: petclinic-android-test + path: demo-apps/PetClinic-Android/build/outputs/apk/androidTest/debug/PetClinic-debug-androidTest.apk + retention-days: 7 + + - name: Build Artifacts - TestSpec + uses: actions/upload-artifact@v4 + with: + name: petclinic-android-test-spec + path: demo-apps/PetClinic-Android/deviceFarmTestSpec.yml + retention-days: 7 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.SOAKTEST_OIDC_ROLE }} + aws-region: ${{ secrets.SOAKTEST_AWS_REGION }} + role-session-name: GitHubActions-SoakTest + audience: sts.amazonaws.com + + - name: Upload TestSpec to S3 + run: | + # Upload test specification + set -o pipefail && aws s3 cp ${{ github.workspace }}/demo-apps/PetClinic-Android/deviceFarmTestSpec.yml s3://${{ secrets.SOAKTEST_BUCKET_NAME }}/testSpec/testSpec_$(date +%Y%m%d_%H%M%S).yaml --region ${{ secrets.SOAKTEST_AWS_REGION }} --metadata "commitHash=${{ github.sha }}" + - name: Upload TestPackage to S3 + run: | + # Upload test package + set -o pipefail && aws s3 cp ${{ github.workspace }}/demo-apps/PetClinic-Android/build/outputs/apk/androidTest/debug/PetClinic-debug-androidTest.apk s3://${{ secrets.SOAKTEST_BUCKET_NAME }}/tests/tests_$(date +%Y%m%d_%H%M%S).apk --region ${{ secrets.SOAKTEST_AWS_REGION }} --metadata "commitHash=${{ github.sha }}" + - name: Upload App to S3 + run: | + # Upload app + set -o pipefail && aws s3 cp ${{ github.workspace }}/demo-apps/PetClinic-Android/build/outputs/apk/debug/PetClinic-debug.apk s3://${{ secrets.SOAKTEST_BUCKET_NAME }}/app/PetClinic_$(date +%Y%m%d_%H%M%S).apk --region ${{ secrets.SOAKTEST_AWS_REGION }} --metadata "commitHash=${{ github.sha }}" \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/.gitignore b/demo-apps/PetClinic-Android/.gitignore new file mode 100644 index 0000000..7f9c15a --- /dev/null +++ b/demo-apps/PetClinic-Android/.gitignore @@ -0,0 +1,3 @@ +.kotlin +.gradle +build \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/Config b/demo-apps/PetClinic-Android/Config new file mode 100644 index 0000000..896bca6 --- /dev/null +++ b/demo-apps/PetClinic-Android/Config @@ -0,0 +1,26 @@ +package.PetClinic-Android = { + interfaces = (1.0); + + # Use NoOpBuild. See https://w.amazon.com/index.php/BrazilBuildSystem/NoOpBuild + build-system = no-op; + build-tools = { + 1.0 = { + NoOpBuild = 1.0; + }; + }; + + # Use runtime-dependencies for when you want to bring in additional + # packages when deploying. + # Use dependencies instead if you intend for these dependencies to + # be exported to other packages that build against you. + dependencies = { + 1.0 = { + }; + }; + + runtime-dependencies = { + 1.0 = { + }; + }; + +}; diff --git a/demo-apps/PetClinic-Android/README.md b/demo-apps/PetClinic-Android/README.md new file mode 100644 index 0000000..6fe84e4 --- /dev/null +++ b/demo-apps/PetClinic-Android/README.md @@ -0,0 +1,56 @@ +# Pet Clinic Android App + +A simple Android frontend for the [application-signals-demo](https://github.com/aws-observability/application-signals-demo) PetClinic application. + +## Requirements + +- Android Studio Hedgehog | 2023.1.1 or newer +- Android SDK 34 +- Minimum SDK 24 (Android 7.0) + +## Building + +1. Open the project in Android Studio +2. Sync the project with Gradle files +3. Run the app on an emulator or physical device + +## Instrumentation with AWS OTEL Android + +### 1. Add Dependencies + +Add to your app's `build.gradle.kts`: + +```kotlin +dependencies { + implementation("software.amazon.opentelemetry.android:agent:1.0.0") + + // For HTTP instrumentation + byteBuddy("io.opentelemetry.android.instrumentation:okhttp3-agent:0.15.0-alpha") +} +``` + +### 2. Create Configuration File + +Create `app/src/main/res/raw/aws_config.json`: + +```json +{ + "aws": { + "region": "us-east-1", + "rumAppMonitorId": "" + }, + "otelResourceAttributes": { + "service.name": "PetClinic-Android", + "service.version": "1.0.0", + "deployment.environment": "production" + } +} +``` + +### 3. That's it! + +The agent automatically initializes and collects telemetry including: +- Activity lifecycle events +- Network requests +- Crashes and ANRs +- UI performance metrics diff --git a/demo-apps/PetClinic-Android/build.gradle.kts b/demo-apps/PetClinic-Android/build.gradle.kts new file mode 100644 index 0000000..f100a88 --- /dev/null +++ b/demo-apps/PetClinic-Android/build.gradle.kts @@ -0,0 +1,104 @@ +plugins { + id("com.android.application") version "8.9.1" + id("org.jetbrains.kotlin.android") version "2.0.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" + kotlin("plugin.serialization") version "2.1.21" + id("net.bytebuddy.byte-buddy-gradle-plugin") version "1.17.6" +} + +android { + namespace = "com.example.petclinic" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.petclinic" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "com.example.petclinic.PetClinicTestRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Instrument the app w/ ADOT Android + implementation("software.amazon.opentelemetry.android:core:1.0.0-beta") + implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.47.0") + implementation("io.opentelemetry:opentelemetry-sdk:1.47.0") + // Automated HTTP client instrumentation with ByteBuddy (optional but recommended) + byteBuddy("io.opentelemetry.android.instrumentation:okhttp3-agent:0.15.0-alpha") + byteBuddy("io.opentelemetry.android.instrumentation:httpurlconnection-agent:0.15.0-alpha") + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") + implementation("androidx.activity:activity-compose:1.9.1") + implementation("androidx.appcompat:appcompat:1.7.0") + + // Compose BOM and dependencies + implementation(platform("androidx.compose:compose-bom:2024.06.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material3:material3-window-size-class") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.compose.material:material-icons-extended:1.6.8") + + // ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") + + // Networking + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // JSON parsing + implementation("com.squareup.moshi:moshi:1.15.0") + implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Image loading + implementation("io.coil-kt:coil-compose:2.6.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/demo-apps/PetClinic-Android/collector.yaml b/demo-apps/PetClinic-Android/collector.yaml new file mode 100644 index 0000000..af4f2a1 --- /dev/null +++ b/demo-apps/PetClinic-Android/collector.yaml @@ -0,0 +1,29 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 +extensions: +exporters: + file/logs: + path: /etc/out/logs.txt + flush_interval: 5 + file/traces: + path: /etc/out/traces.txt + flush_interval: 5 + otlphttp: + traces_endpoint: "http://jaeger:4318/v1/traces" + debug: + verbosity: normal + debug/detailed: + verbosity: detailed +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug/detailed, file/traces, otlphttp] + logs: + receivers: [otlp] + exporters: [debug/detailed, file/logs] \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/compose.yaml b/demo-apps/PetClinic-Android/compose.yaml new file mode 100644 index 0000000..f9f0bce --- /dev/null +++ b/demo-apps/PetClinic-Android/compose.yaml @@ -0,0 +1,19 @@ +services: + collector: + image: "otel/opentelemetry-collector-contrib@sha256:0076e6c250bef16968e29e8f94177b87b5a851c79ce14b270e657f5f655b9e04" + volumes: + - ./collector.yaml:/etc/demo-collector.yaml + - ./out:/etc/out + entrypoint: ["/otelcol-contrib"] + command: ["--config", "/etc/demo-collector.yaml"] + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + + jaeger: + image: "jaegertracing/all-in-one:1.60@sha256:4fd2d70fa347d6a47e79fcb06b1c177e6079f92cba88b083153d56263082135e" + environment: + - COLLECTOR_OTLP_ENABLED=true + - COLLECTOR_OTLP_HTTP_HOST_PORT=0.0.0.0:4318 + ports: + - "16686:16686" # UI \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/deviceFarmTestSpec.yml b/demo-apps/PetClinic-Android/deviceFarmTestSpec.yml new file mode 100644 index 0000000..f145e34 --- /dev/null +++ b/demo-apps/PetClinic-Android/deviceFarmTestSpec.yml @@ -0,0 +1,84 @@ +version: 0.1 + +android_test_host: amazon_linux_2 + +phases: + + install: + commands: + + pre_test: + commands: + - echo "setting Device Farm Job ID" + + test: + commands: + - echo "Starting the Instrumentation test" + - | + adb -s $DEVICEFARM_DEVICE_UDID shell "am instrument -r -w \ + -e deviceFarmJobId "" \ + -e appMonitorId "0d99d210-2a2c-44c5-b47d-6e7330bd3349" \ + -e appMonitorRegion "us-west-2" \ + -e environmentSuffix "-gamma" \ + --no-window-animation \ + com.example.petclinic.test/com.example.petclinic.PetClinicTestRunner 2>&1 || echo \": -1\"" | + tee $DEVICEFARM_LOG_DIR/instrument.log + + - |- + DID_ANY_TESTS_START=$(grep "INSTRUMENTATION_STATUS_CODE: 1" $DEVICEFARM_LOG_DIR/instrument.log | + wc -l); + TESTS_PASSED=$(grep "INSTRUMENTATION_STATUS_CODE: 0" $DEVICEFARM_LOG_DIR/instrument.log | wc -l); + TESTS_ERRORED=$(grep "INSTRUMENTATION_STATUS_CODE: -1" $DEVICEFARM_LOG_DIR/instrument.log | wc -l); + TESTS_FAILED=$(grep "INSTRUMENTATION_STATUS_CODE: -2" $DEVICEFARM_LOG_DIR/instrument.log | wc -l); + TESTS_IGNORED=$(grep "INSTRUMENTATION_STATUS_CODE: -3" $DEVICEFARM_LOG_DIR/instrument.log | wc -l); + TESTS_ASSUMPTION_FAILED=$(grep "INSTRUMENTATION_STATUS_CODE: -4" $DEVICEFARM_LOG_DIR/instrument.log | + wc -l); + TESTS_PROCESSES_CRASHED=$(grep "INSTRUMENTATION_RESULT: shortMsg=Process crashed." \ + $DEVICEFARM_LOG_DIR/instrument.log | wc -l); + + - >- + echo "From your instrumentation output, it appears that $TESTS_PASSED tests passed, + $TESTS_FAILED failed, $TESTS_IGNORED were ignored, $TESTS_ERRORED errored, + and $TESTS_ASSUMPTION_FAILED experienced an assumption failure."; + + echo "These counts are calculated directly from the raw instrumentation output, thus any + redundant lines of output (e.g. a test case that was retried multiple times) could cause + the numbers to not align exactly with your expected number of test cases."; + + - | + if [ $TESTS_PROCESSES_CRASHED -ne 0 ] || [ $TESTS_FAILED -ne 0 ]; + then + echo "App crash or test failure detected! Reopening app to allow crash report to publish..."; + PACKAGE_NAME=$(echo $DEVICEFARM_TEST_PACKAGE_NAME | sed 's/\.test$//') + adb -s $DEVICEFARM_DEVICE_UDID shell monkey -p $PACKAGE_NAME -c android.intent.category.LAUNCHER 1 + echo "Waiting 20 seconds for crash report to publish..." + sleep 20 + echo "Crash report publishing window complete" + fi; + + - | + if [ $DID_ANY_TESTS_START -eq 0 ]; + then + echo "Marking the test suite as failed because no tests started!"; + false; + elif [ $TESTS_FAILED -ne 0 ]; + then + echo "Marking the test suite as failed because $TESTS_FAILED tests failed!"; + false; + elif [ $TESTS_ERRORED -ne 0 ]; + then + echo "Marking the test suite as failed because $TESTS_ERRORED tests errored!"; + false; + elif [ $TESTS_PROCESSES_CRASHED -ne 0 ]; + then + echo "Marking the test suite as failed because the application-under-test crashed during the test!"; + false; + else + echo "Tests passed!"; + fi; + + post_test: + commands: + +artifacts: + - $DEVICEFARM_LOG_DIR \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/docs/app-spec.md b/demo-apps/PetClinic-Android/docs/app-spec.md new file mode 100644 index 0000000..570e9a2 --- /dev/null +++ b/demo-apps/PetClinic-Android/docs/app-spec.md @@ -0,0 +1,32 @@ +## Overview + +This document describes the intended functionality of the PetClinic Android app. + +## Tech stack + +- "Black box" API. See: [PET_CLINIC_API_REFERENCE.md](../../PET_CLINIC_API_REFERENCE.md) for a comprehensive view into what the API is expected to provide +- OkHttp3 for networked calls + +## Features + +### Nav bar + +The app should be separated into different core screens. We will use Navigation with Compose to create a Nav Bar. The Nav Bar should be its own Fragment. + +### Home screen + +A simple home screen that maybe has some blurb about what this app is (it's the Application Signals demo app). Include a picture of "$endpoint/images/pets.png". + +### Owners + +- There should be a screen that lets you see the table of owners retrieved from GetOwners API. +- User should be able to filter the table with fuzzy searching +- Clicking on an Owner name should take you to a separate screen that lets you manage the owner with the following actions: + 1. Edit the owner, a wizard powered by UpdateOwner API + 2. Add a new pet for the owner, powered by AddPet API +- The owner management page should show a list of Pets, and be able to show you a breakdown of vet Visits (GetOwnerWithVisits API) +- You should also be able to register as a new owner, using the AddOwner API + +### Veterinarians + +- There should be a screen that lets you see all vets via the GetVets API \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/gradle.properties b/demo-apps/PetClinic-Android/gradle.properties new file mode 100644 index 0000000..2cbd6d1 --- /dev/null +++ b/demo-apps/PetClinic-Android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true diff --git a/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.jar b/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.properties b/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/demo-apps/PetClinic-Android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/demo-apps/PetClinic-Android/gradlew b/demo-apps/PetClinic-Android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/demo-apps/PetClinic-Android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/demo-apps/PetClinic-Android/gradlew.bat b/demo-apps/PetClinic-Android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/demo-apps/PetClinic-Android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/demo-apps/PetClinic-Android/proguard-rules.pro b/demo-apps/PetClinic-Android/proguard-rules.pro new file mode 100644 index 0000000..2f9dc5a --- /dev/null +++ b/demo-apps/PetClinic-Android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/demo-apps/PetClinic-Android/settings.gradle.kts b/demo-apps/PetClinic-Android/settings.gradle.kts new file mode 100644 index 0000000..31f7ff6 --- /dev/null +++ b/demo-apps/PetClinic-Android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + mavenLocal() + mavenCentral() + google() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + } +} + +rootProject.name = "PetClinic" diff --git a/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/PetClinicTestRunner.kt b/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/PetClinicTestRunner.kt new file mode 100644 index 0000000..96b767b --- /dev/null +++ b/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/PetClinicTestRunner.kt @@ -0,0 +1,14 @@ +package com.example.petclinic + +import android.os.Bundle +import androidx.test.runner.AndroidJUnitRunner + +class PetClinicTestRunner : AndroidJUnitRunner() { + override fun onCreate(arguments: Bundle) { + arguments.getString("deviceFarmJobId")?.let { PetClinicApplication.deviceFarmJobId = it } + arguments.getString("appMonitorId")?.let { PetClinicApplication.appMonitorId = it } + arguments.getString("appMonitorRegion")?.let { PetClinicApplication.appMonitorRegion = it } + arguments.getString("environmentSuffix")?.let { PetClinicApplication.environmentSuffix = it } + super.onCreate(arguments) + } +} diff --git a/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/UserInteractionTest.kt b/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/UserInteractionTest.kt new file mode 100644 index 0000000..e78ae58 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/androidTest/java/com/example/petclinic/UserInteractionTest.kt @@ -0,0 +1,125 @@ +package com.example.petclinic + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UserInteractionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun testGenerateComprehensiveTelemetry() { + composeTestRule.waitForIdle() + generateAllTelemetry() + Thread.sleep(300) + } + + @Test + fun testGenerateCrashTelemetry() { + composeTestRule.waitForIdle() + scrollToBottom() + triggerCrash() + // App will crash and relaunch to capture crash telemetry + } + + private fun generateAllTelemetry() { + navigateToOwnersScreen() + navigateToVetsScreen() + navigateToHomeScreen() + testUIJankFeature() + performFinalNavigationRound() + performDestructiveTests() + } + + private fun navigateToOwnersScreen() { + composeTestRule.onNodeWithText("Owners").performClick() + composeTestRule.waitForIdle() + Thread.sleep(300) + } + + private fun navigateToVetsScreen() { + composeTestRule.onNodeWithText("Veterinarians").performClick() + composeTestRule.waitForIdle() + Thread.sleep(300) + } + + private fun navigateToHomeScreen() { + composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.waitForIdle() + Thread.sleep(300) + } + + private fun testUIJankFeature() { + scrollToBottom() + try { + composeTestRule.onNodeWithText("🐌 Start UI Jank").performClick() + composeTestRule.waitForIdle() + composeTestRule.waitUntil(3000) { + composeTestRule.onAllNodesWithText("✅ Stop UI Jank").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("✅ Stop UI Jank").performClick() + composeTestRule.waitForIdle() + } catch (e: Exception) { + // Continue if buttons not found + } + } + + private fun performFinalNavigationRound() { + navigateToOwnersScreen() + navigateToVetsScreen() + navigateToHomeScreen() + } + + private fun performDestructiveTests() { + scrollToBottom() + + // Test API Call + tryClick("🌐 Test API Call") + + // Network Error 404 + tryClick("🚫 Simulate Network Error 404") + dismissAlert() + + // Network Error 500 + tryClick("🚫 Simulate Network Error 500") + dismissAlert() + + // Trigger ANR + tryClick("⏰ Trigger ANR") + } + + private fun triggerCrash() { + tryClick("💥 Trigger App Crash") + } + + private fun tryClick(text: String) { + try { + composeTestRule.onNodeWithText(text).performClick() + Thread.sleep(1000) + } catch (e: Exception) { + // Continue if button not found + } + } + + private fun dismissAlert() { + try { + composeTestRule.onNodeWithText("OK").performClick() + Thread.sleep(500) + } catch (e: Exception) { + // Continue if alert not shown + } + } + + private fun scrollToBottom() { + composeTestRule.onRoot().performTouchInput { + swipeUp(startY = centerY + 300, endY = centerY - 300, durationMillis = 800) + } + Thread.sleep(500) + } +} diff --git a/demo-apps/PetClinic-Android/src/main/AndroidManifest.xml b/demo-apps/PetClinic-Android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..067d974 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo-apps/PetClinic-Android/src/main/embrace-config.json b/demo-apps/PetClinic-Android/src/main/embrace-config.json new file mode 100644 index 0000000..e335b67 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/embrace-config.json @@ -0,0 +1,5 @@ +{ + "app_id": "spqci", + "api_token": "86c8cdce337e4da3a57ba2efac208027", + "ndk_enabled": false +} \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/AddOwnerActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/AddOwnerActivity.kt new file mode 100644 index 0000000..9679253 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/AddOwnerActivity.kt @@ -0,0 +1,21 @@ +package com.example.petclinic + +import androidx.compose.runtime.Composable +import com.example.petclinic.ui.screen.AddOwnerScreen + +/** + * Activity that displays the add owner form + */ +class AddOwnerActivity : BaseActivity() { + + @Composable + override fun ActivityContent() { + AddOwnerScreen( + onNavigateBack = { + finish() // Close this activity and return to previous one + } + ) + } + + override fun getSelectedBottomNavRoute(): String? = null // No bottom nav for form screens +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/BaseActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/BaseActivity.kt new file mode 100644 index 0000000..38524e9 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/BaseActivity.kt @@ -0,0 +1,76 @@ +package com.example.petclinic + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import com.example.petclinic.ui.components.BottomNavigationBar +import com.example.petclinic.ui.fragment.BottomNavigationFragment +import com.example.petclinic.ui.theme.PetClinicTheme + +/** + * Base activity that provides common functionality for all activities + */ +abstract class BaseActivity : FragmentActivity() { + + /** + * Override this to provide the content for the activity + */ + @Composable + abstract fun ActivityContent() + + /** + * Override this to specify which bottom nav item should be selected + * Return null if this activity shouldn't show bottom navigation + */ + abstract fun getSelectedBottomNavRoute(): String? + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + PetClinicTheme { + val selectedRoute = getSelectedBottomNavRoute() + + Scaffold( + bottomBar = { + selectedRoute?.let { route -> + BottomNavigationBar( + selectedRoute = route, + onNavigate = { destination -> + navigateToActivity(destination) + } + ) + } + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + ActivityContent() + } + } + } + } + } + + private fun navigateToActivity(route: String) { + val intent = when (route) { + "home" -> Intent(this, MainActivity::class.java) + "owners" -> Intent(this, OwnersActivity::class.java) + "vets" -> Intent(this, VetsActivity::class.java) + else -> return + } + + if (intent.component?.className != this::class.java.name) { + startActivity(intent) + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/MainActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/MainActivity.kt new file mode 100644 index 0000000..51fa344 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/MainActivity.kt @@ -0,0 +1,16 @@ +package com.example.petclinic + +import androidx.compose.runtime.Composable +import com.example.petclinic.ui.screen.HomeScreen + +/** + * Main activity that displays the home screen + */ +class MainActivity : BaseActivity() { + @Composable + override fun ActivityContent() { + HomeScreen() + } + + override fun getSelectedBottomNavRoute(): String = "home" +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnerDetailActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnerDetailActivity.kt new file mode 100644 index 0000000..96c3bfc --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnerDetailActivity.kt @@ -0,0 +1,24 @@ +package com.example.petclinic + +import androidx.compose.runtime.Composable +import com.example.petclinic.ui.screen.OwnerDetailScreen + +/** + * Activity that displays owner details + */ +class OwnerDetailActivity : BaseActivity() { + + @Composable + override fun ActivityContent() { + val ownerId = intent.getIntExtra("ownerId", 0) + + OwnerDetailScreen( + ownerId = ownerId, + onNavigateBack = { + finish() // Close this activity and return to previous one + } + ) + } + + override fun getSelectedBottomNavRoute(): String? = null // No bottom nav for detail screens +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnersActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnersActivity.kt new file mode 100644 index 0000000..7d27753 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/OwnersActivity.kt @@ -0,0 +1,51 @@ +package com.example.petclinic + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.example.petclinic.ui.fragment.AddOwnerFragment +import com.example.petclinic.ui.screen.OwnersScreen + +/** + * Activity that displays the owners list + */ +class OwnersActivity : BaseActivity() { + + // State to trigger recomposition when activity resumes + private var refreshTrigger by mutableStateOf(0) + private var isFirstResume = true + + override fun onResume() { + super.onResume() + // Skip first resume (initial load) - let ViewModel init handle it + if (isFirstResume) { + isFirstResume = false + } else { + // Only increment on subsequent resumes (when returning from other screens) + refreshTrigger++ + } + } + + @Composable + override fun ActivityContent() { + // Pass refreshTrigger to force recomposition + OwnersScreen( + onOwnerClick = { ownerId -> + val intent = Intent(this, OwnerDetailActivity::class.java) + intent.putExtra("ownerId", ownerId) + startActivity(intent) + }, + onAddOwnerClick = { + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, AddOwnerFragment()) + .addToBackStack(null) + .commit() + }, + refreshTrigger = refreshTrigger + ) + } + + override fun getSelectedBottomNavRoute(): String = "owners" +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/PetClinicApplication.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/PetClinicApplication.kt new file mode 100644 index 0000000..5a6b046 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/PetClinicApplication.kt @@ -0,0 +1,40 @@ +package com.example.petclinic + +import android.app.Application +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter +import io.opentelemetry.sdk.resources.Resource +import software.amazon.opentelemetry.android.OpenTelemetryRumClient + +class PetClinicApplication : Application() { + companion object { + var deviceFarmJobId: String = "test-device-farm-job-id" + var appMonitorId: String = "test-app-monitor-id-uuid" + var appMonitorRegion: String = "us-west-2" + var environmentSuffix: String = "" + } + + override fun onCreate() { + super.onCreate() + val exportEndpoint = "https://dataplane.rum${environmentSuffix}.${appMonitorRegion}.amazonaws.com/v1/rum" + + OpenTelemetryRumClient { + androidApplication = this@PetClinicApplication + awsRum { + region = appMonitorRegion + appMonitorId = Companion.appMonitorId + } + spanExporter = OtlpHttpSpanExporter.builder() + .setEndpoint(exportEndpoint) + .build() + logRecordExporter = OtlpHttpLogRecordExporter.builder() + .setEndpoint(exportEndpoint) + .build() + otelResource = Resource.builder() + .put("service.name", "PetClinic-Android") + .put("service.version", "1.0.0") + .put("deviceFarmJobId", deviceFarmJobId) + .build() + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/VetsActivity.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/VetsActivity.kt new file mode 100644 index 0000000..8dad474 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/VetsActivity.kt @@ -0,0 +1,17 @@ +package com.example.petclinic + +import androidx.compose.runtime.Composable +import com.example.petclinic.ui.screen.VetsScreen + +/** + * Activity that displays the veterinarians list + */ +class VetsActivity : BaseActivity() { + + @Composable + override fun ActivityContent() { + VetsScreen() + } + + override fun getSelectedBottomNavRoute(): String = "vets" +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDao.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDao.kt new file mode 100644 index 0000000..d2a11ec --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDao.kt @@ -0,0 +1,34 @@ +package com.example.petclinic.data.dao + +import com.example.petclinic.data.model.* +import com.example.petclinic.data.network.ApiResult + +/** + * Data Access Object interface for Pet Clinic API operations + */ +interface PetClinicDao { + + // Owner operations + suspend fun getOwners(): ApiResult> + suspend fun getOwner(ownerId: Int): ApiResult + suspend fun getOwnerWithVisits(ownerId: Int): ApiResult + suspend fun updateOwner(ownerId: Int, ownerRequest: OwnerRequest): ApiResult + suspend fun addOwner(ownerRequest: OwnerRequest): ApiResult + + // Pet operations + suspend fun getPetTypes(): ApiResult> + suspend fun getPet(ownerId: Int, petId: Int): ApiResult + suspend fun updatePet(ownerId: Int, petId: Int, petRequest: PetRequest): ApiResult + suspend fun addPet(ownerId: Int, petRequest: PetRequest): ApiResult + suspend fun diagnosePet(ownerId: Int, petId: Int): ApiResult + + // Visit operations + suspend fun getVisits(ownerId: Int, petId: Int): ApiResult + suspend fun addVisit(ownerId: Int, petId: Int, visit: Visit): ApiResult + + // Vet operations + suspend fun getVets(): ApiResult> + + // Nutrition operations + suspend fun getPetNutrition(petType: String): ApiResult +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDaoImpl.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDaoImpl.kt new file mode 100644 index 0000000..39e1a23 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/dao/PetClinicDaoImpl.kt @@ -0,0 +1,235 @@ +package com.example.petclinic.data.dao + +import com.example.petclinic.data.model.* +import com.example.petclinic.data.network.* +import com.squareup.moshi.Types +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.lang.reflect.Type + +/** + * Implementation of PetClinicDao using OkHttp client + */ +class PetClinicDaoImpl( + private val baseUrl: String = "http://10.0.2.2:8080" // Android emulator localhost +) : PetClinicDao { + + private val client = HttpClient.okHttpClient + private val moshi = HttpClient.moshi + + // JSON adapters with explicit types + private val ownerAdapter = moshi.adapter(Owner::class.java) + private val ownerListType: Type = Types.newParameterizedType(List::class.java, Owner::class.java) + private val ownerListAdapter = moshi.adapter>(ownerListType) + private val ownerRequestAdapter = moshi.adapter(OwnerRequest::class.java) + private val petAdapter = moshi.adapter(Pet::class.java) + private val petTypeListType: Type = Types.newParameterizedType(List::class.java, PetType::class.java) + private val petTypeListAdapter = moshi.adapter>(petTypeListType) + private val petRequestAdapter = moshi.adapter(PetRequest::class.java) + private val visitsAdapter = moshi.adapter(Visits::class.java) + private val visitAdapter = moshi.adapter(Visit::class.java) + private val vetListType: Type = Types.newParameterizedType(List::class.java, Vet::class.java) + private val vetListAdapter = moshi.adapter>(vetListType) + private val petNutritionAdapter = moshi.adapter(PetNutrition::class.java) + + override suspend fun getOwners(): ApiResult> = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/customer/owners") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + ownerListAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse owners list") + } + } + + override suspend fun getOwner(ownerId: Int): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/customer/owners/$ownerId") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + ownerAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse owner") + } + } + + override suspend fun getOwnerWithVisits(ownerId: Int): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/gateway/owners/$ownerId") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + ownerAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse owner with visits") + } + } + + override suspend fun updateOwner(ownerId: Int, ownerRequest: OwnerRequest): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val json = ownerRequestAdapter.toJson(ownerRequest) + val request = Request.Builder() + .url("$baseUrl/api/customer/owners/$ownerId") + .putJsonBody(json) + .build() + + client.newCall(request).execute() + Unit + } + } + + override suspend fun addOwner(ownerRequest: OwnerRequest): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val json = ownerRequestAdapter.toJson(ownerRequest) + val request = Request.Builder() + .url("$baseUrl/api/customer/owners") + .jsonBody(json) + .build() + + client.newCall(request).execute() + Unit + } + } + + override suspend fun getPetTypes(): ApiResult> = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/customer/petTypes") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + petTypeListAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse pet types") + } + } + + override suspend fun getPet(ownerId: Int, petId: Int): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/customer/owners/$ownerId/pets/$petId") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + petAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse pet") + } + } + + override suspend fun updatePet(ownerId: Int, petId: Int, petRequest: PetRequest): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val json = petRequestAdapter.toJson(petRequest) + val request = Request.Builder() + .url("$baseUrl/api/customer/owners/$ownerId/pets/$petId") + .putJsonBody(json) + .build() + + client.newCall(request).execute() + Unit + } + } + + override suspend fun addPet(ownerId: Int, petRequest: PetRequest): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val json = petRequestAdapter.toJson(petRequest) + val request = Request.Builder() + .url("$baseUrl/api/customer/owners/$ownerId/pets") + .jsonBody(json) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + petAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse new pet") + } + } + + override suspend fun diagnosePet(ownerId: Int, petId: Int): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/customer/diagnose/owners/$ownerId/pets/$petId") + .get() + .build() + + client.newCall(request).execute() + Unit + } + } + + override suspend fun getVisits(ownerId: Int, petId: Int): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/visit/owners/$ownerId/pets/$petId/visits") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + visitsAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse visits") + } + } + + override suspend fun addVisit(ownerId: Int, petId: Int, visit: Visit): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val json = visitAdapter.toJson(visit) + val request = Request.Builder() + .url("$baseUrl/api/visit/owners/$ownerId/pets/$petId/visits") + .jsonBody(json) + .build() + + val response = client.newCall(request).execute() + response.getBodyString() + } + } + + override suspend fun getVets(): ApiResult> = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/vet/vets") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + vetListAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse vets") + } + } + + override suspend fun getPetNutrition(petType: String): ApiResult = withContext(Dispatchers.IO) { + safeApiCall { + val request = Request.Builder() + .url("$baseUrl/api/nutrition/facts/$petType") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.getBodyString() + + petNutritionAdapter.fromJson(responseBody) + ?: throw ApiException.ParseException("Failed to parse pet nutrition") + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/di/DataModule.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/di/DataModule.kt new file mode 100644 index 0000000..6648f07 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/di/DataModule.kt @@ -0,0 +1,29 @@ +package com.example.petclinic.data.di + +import com.example.petclinic.data.dao.PetClinicDao +import com.example.petclinic.data.dao.PetClinicDaoImpl +import com.example.petclinic.data.network.ApiConfig + +/** + * Simple dependency injection for data layer + */ +object DataModule { + + private var _petClinicDao: PetClinicDao? = null + + /** + * Get singleton instance of PetClinicDao + */ + fun getPetClinicDao(baseUrl: String = ApiConfig.getBaseUrl()): PetClinicDao { + return _petClinicDao ?: synchronized(this) { + _petClinicDao ?: PetClinicDaoImpl(baseUrl).also { _petClinicDao = it } + } + } + + /** + * Reset the DAO instance (useful for testing) + */ + fun resetDao() { + _petClinicDao = null + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Owner.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Owner.kt new file mode 100644 index 0000000..58d3dbb --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Owner.kt @@ -0,0 +1,36 @@ +package com.example.petclinic.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Owner( + @Json(name = "id") + val id: Int, + @Json(name = "firstName") + val firstName: String, + @Json(name = "lastName") + val lastName: String, + @Json(name = "address") + val address: String, + @Json(name = "city") + val city: String, + @Json(name = "telephone") + val telephone: String, + @Json(name = "pets") + val pets: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class OwnerRequest( + @Json(name = "firstName") + val firstName: String, + @Json(name = "lastName") + val lastName: String, + @Json(name = "address") + val address: String, + @Json(name = "city") + val city: String, + @Json(name = "telephone") + val telephone: String +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Pet.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Pet.kt new file mode 100644 index 0000000..7ee9899 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Pet.kt @@ -0,0 +1,36 @@ +package com.example.petclinic.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Pet( + @Json(name = "id") + val id: Int, + @Json(name = "name") + val name: String, + @Json(name = "birthDate") + val birthDate: String, + @Json(name = "type") + val type: PetType, + @Json(name = "visits") + val visits: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class PetType( + @Json(name = "id") + val id: Int, + @Json(name = "name") + val name: String +) + +@JsonClass(generateAdapter = true) +data class PetRequest( + @Json(name = "name") + val name: String, + @Json(name = "birthDate") + val birthDate: String, + @Json(name = "typeId") + val typeId: Int +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/PetNutrition.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/PetNutrition.kt new file mode 100644 index 0000000..3aad8c0 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/PetNutrition.kt @@ -0,0 +1,9 @@ +package com.example.petclinic.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PetNutrition( + @Json(name = "facts") val facts: String +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Vet.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Vet.kt new file mode 100644 index 0000000..2a9dbb2 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Vet.kt @@ -0,0 +1,24 @@ +package com.example.petclinic.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Vet( + @Json(name = "id") + val id: Int, + @Json(name = "firstName") + val firstName: String, + @Json(name = "lastName") + val lastName: String, + @Json(name = "specialties") + val specialties: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class Specialty( + @Json(name = "id") + val id: Int, + @Json(name = "name") + val name: String +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Visit.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Visit.kt new file mode 100644 index 0000000..0108c00 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/model/Visit.kt @@ -0,0 +1,22 @@ +package com.example.petclinic.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Visit( + @Json(name = "id") + val id: Int?, + @Json(name = "petId") + val petId: Int?, + @Json(name = "date") + val date: String?, + @Json(name = "description") + val description: String? +) + +@JsonClass(generateAdapter = true) +data class Visits( + @Json(name = "items") + val items: List = emptyList() +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiConfig.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiConfig.kt new file mode 100644 index 0000000..5a894be --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiConfig.kt @@ -0,0 +1,32 @@ +package com.example.petclinic.data.network + +/** + * Configuration for Pet Clinic API + */ +object ApiConfig { + + // Default to Android emulator localhost + // Change this to your actual API gateway URL when deploying + const val DEFAULT_BASE_URL = "http://a61dafdaceaa340d5b3fc9d3a00f71c6-746455772.us-east-1.elb.amazonaws.com" + + // Alternative URLs for different environments + const val LOCALHOST_BASE_URL = "http://localhost:8080" + const val PRODUCTION_BASE_URL = "https://your-api-gateway-url.com" + + /** + * Get the appropriate base URL based on the environment + */ + fun getBaseUrl(environment: Environment = Environment.DEVELOPMENT): String { + return when (environment) { + Environment.DEVELOPMENT -> DEFAULT_BASE_URL + Environment.LOCALHOST -> LOCALHOST_BASE_URL + Environment.PRODUCTION -> PRODUCTION_BASE_URL + } + } + + enum class Environment { + DEVELOPMENT, + LOCALHOST, + PRODUCTION + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiResult.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiResult.kt new file mode 100644 index 0000000..15affeb --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/ApiResult.kt @@ -0,0 +1,42 @@ +package com.example.petclinic.data.network + +/** + * A generic wrapper for API responses that can represent success, error, or loading states + */ +sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val exception: ApiException) : ApiResult() + data object Loading : ApiResult() +} + +/** + * Custom exception for API errors + */ +sealed class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkException(message: String, cause: Throwable? = null) : ApiException(message, cause) + class HttpException(val code: Int, message: String) : ApiException(message) + class ParseException(message: String, cause: Throwable? = null) : ApiException(message, cause) + class UnknownException(message: String, cause: Throwable? = null) : ApiException(message, cause) +} + +/** + * Extension function to safely execute API calls and wrap results + */ +suspend fun safeApiCall(apiCall: suspend () -> T): ApiResult { + return try { + ApiResult.Success(apiCall()) + } catch (e: Exception) { + ApiResult.Error( + when (e) { + is java.net.UnknownHostException, + is java.net.ConnectException, + is java.net.SocketTimeoutException -> ApiException.NetworkException( + "Network error: ${e.message}", + e + ) + is ApiException -> e + else -> ApiException.UnknownException("Unknown error: ${e.message}", e) + } + ) + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/HttpClient.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/HttpClient.kt new file mode 100644 index 0000000..d6c15eb --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/network/HttpClient.kt @@ -0,0 +1,85 @@ +package com.example.petclinic.data.network + +import android.util.Log +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * HTTP client configuration for Pet Clinic API + */ +object HttpClient { + + private const val TAG = "PetClinicHttpClient" + private const val CONNECT_TIMEOUT = 30L + private const val READ_TIMEOUT = 30L + private const val WRITE_TIMEOUT = 30L + + val moshi: Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) + .addInterceptor(createLoggingInterceptor()) + .addInterceptor(createErrorInterceptor()) + .build() + } + + private fun createLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor { message -> + Log.d(TAG, message) + }.apply { + // Use BASIC for better performance - logs URL, method, status code and time + // BODY logs entire request/response which is VERY slow for large responses + level = HttpLoggingInterceptor.Level.BASIC + } + } + + private fun createErrorInterceptor(): Interceptor { + return Interceptor { chain -> + val request = chain.request() + val response = chain.proceed(request) + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + throw ApiException.HttpException( + code = response.code, + message = "HTTP ${response.code}: $errorBody" + ) + } + + response + } + } +} + +/** + * Extension functions for OkHttp Request building + */ +fun Request.Builder.jsonBody(json: String): Request.Builder { + return this.post(json.toRequestBody("application/json".toMediaType())) +} + +fun Request.Builder.putJsonBody(json: String): Request.Builder { + return this.put(json.toRequestBody("application/json".toMediaType())) +} + +/** + * Extension function to safely get response body as string + */ +@Throws(IOException::class) +fun Response.getBodyString(): String { + return body?.string() ?: throw IOException("Response body is null") +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/repository/PetClinicRepository.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/repository/PetClinicRepository.kt new file mode 100644 index 0000000..1c05c0f --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/data/repository/PetClinicRepository.kt @@ -0,0 +1,91 @@ +package com.example.petclinic.data.repository + +import com.example.petclinic.data.dao.PetClinicDao +import com.example.petclinic.data.di.DataModule +import com.example.petclinic.data.model.* +import com.example.petclinic.data.network.ApiResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Repository class that provides a clean API for UI layer to interact with Pet Clinic data + */ +class PetClinicRepository( + private val dao: PetClinicDao = DataModule.getPetClinicDao() +) { + + /** + * Get all owners as a Flow + */ + fun getOwners(): Flow>> = flow { + emit(ApiResult.Loading) + emit(dao.getOwners()) + } + + /** + * Get owner by ID as a Flow + */ + fun getOwner(ownerId: Int): Flow> = flow { + emit(ApiResult.Loading) + emit(dao.getOwner(ownerId)) + } + + /** + * Get owner with visits as a Flow + */ + fun getOwnerWithVisits(ownerId: Int): Flow> = flow { + emit(ApiResult.Loading) + emit(dao.getOwnerWithVisits(ownerId)) + } + + /** + * Get all veterinarians as a Flow + */ + fun getVets(): Flow>> = flow { + emit(ApiResult.Loading) + emit(dao.getVets()) + } + + /** + * Get pet types as a Flow + */ + fun getPetTypes(): Flow>> = flow { + emit(ApiResult.Loading) + emit(dao.getPetTypes()) + } + + /** + * Add a new owner + */ + suspend fun addOwner(ownerRequest: OwnerRequest): ApiResult { + return dao.addOwner(ownerRequest) + } + + /** + * Update an existing owner + */ + suspend fun updateOwner(ownerId: Int, ownerRequest: OwnerRequest): ApiResult { + return dao.updateOwner(ownerId, ownerRequest) + } + + /** + * Add a new pet to an owner + */ + suspend fun addPet(ownerId: Int, petRequest: PetRequest): ApiResult { + return dao.addPet(ownerId, petRequest) + } + + /** + * Add a visit for a pet + */ + suspend fun addVisit(ownerId: Int, petId: Int, visit: Visit): ApiResult { + return dao.addVisit(ownerId, petId, visit) + } + + /** + * Get nutrition facts for a pet type + */ + suspend fun getPetNutrition(petType: String): ApiResult { + return dao.getPetNutrition(petType) + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/BottomNavigationBar.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/BottomNavigationBar.kt new file mode 100644 index 0000000..d28315b --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/BottomNavigationBar.kt @@ -0,0 +1,38 @@ +package com.example.petclinic.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +data class BottomNavItem( + val route: String, + val title: String, + val icon: ImageVector +) + +val bottomNavItems = listOf( + BottomNavItem("home", "Home", Icons.Default.Home), + BottomNavItem("owners", "Owners", Icons.Default.People), + BottomNavItem("vets", "Veterinarians", Icons.Default.Person) +) + +@Composable +fun BottomNavigationBar( + selectedRoute: String, + onNavigate: (String) -> Unit +) { + NavigationBar { + bottomNavItems.forEach { item -> + NavigationBarItem( + icon = { Icon(item.icon, contentDescription = item.title) }, + label = { Text(item.title) }, + selected = selectedRoute == item.route, + onClick = { onNavigate(item.route) } + ) + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/Dialogs.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/Dialogs.kt new file mode 100644 index 0000000..cdc43f9 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/components/Dialogs.kt @@ -0,0 +1,248 @@ +package com.example.petclinic.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.example.petclinic.data.model.Owner +import com.example.petclinic.data.model.OwnerRequest +import com.example.petclinic.data.model.PetRequest +import com.example.petclinic.data.model.PetType + +@Composable +fun EditOwnerDialog( + owner: Owner, + onDismiss: () -> Unit, + onSave: (OwnerRequest) -> Unit, + isLoading: Boolean = false +) { + var firstName by remember { mutableStateOf(owner.firstName) } + var lastName by remember { mutableStateOf(owner.lastName) } + var address by remember { mutableStateOf(owner.address) } + var city by remember { mutableStateOf(owner.city) } + var telephone by remember { mutableStateOf(owner.telephone) } + + Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Edit Owner", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = firstName, + onValueChange = { firstName = it }, + label = { Text("First Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + OutlinedTextField( + value = lastName, + onValueChange = { lastName = it }, + label = { Text("Last Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text("Address") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + OutlinedTextField( + value = city, + onValueChange = { city = it }, + label = { Text("City") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + OutlinedTextField( + value = telephone, + onValueChange = { telephone = it }, + label = { Text("Telephone") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onDismiss, + enabled = !isLoading + ) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val ownerRequest = OwnerRequest( + firstName = firstName.trim(), + lastName = lastName.trim(), + address = address.trim(), + city = city.trim(), + telephone = telephone.trim() + ) + onSave(ownerRequest) + }, + enabled = !isLoading && firstName.isNotBlank() && lastName.isNotBlank() && + address.isNotBlank() && city.isNotBlank() && telephone.isNotBlank() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Save") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddPetDialog( + petTypes: List, + onDismiss: () -> Unit, + onSave: (PetRequest) -> Unit +) { + var name by remember { mutableStateOf("") } + var birthDate by remember { mutableStateOf("") } + var selectedPetType by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Add New Pet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Pet Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = birthDate, + onValueChange = { birthDate = it }, + label = { Text("Birth Date (YYYY-MM-DD)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("2020-01-15") } + ) + + // Pet Type Dropdown + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedPetType?.name?.replaceFirstChar { it.uppercase() } ?: "", + onValueChange = { }, + readOnly = true, + label = { Text("Pet Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + petTypes.forEach { petType -> + DropdownMenuItem( + text = { Text(petType.name.replaceFirstChar { it.uppercase() }) }, + onClick = { + selectedPetType = petType + expanded = false + } + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + selectedPetType?.let { petType -> + val petRequest = PetRequest( + name = name.trim(), + birthDate = birthDate.trim(), + typeId = petType.id + ) + onSave(petRequest) + } + }, + enabled = name.isNotBlank() && birthDate.isNotBlank() && selectedPetType != null + ) { + Text("Add Pet") + } + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/AddOwnerFragment.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/AddOwnerFragment.kt new file mode 100644 index 0000000..2217480 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/AddOwnerFragment.kt @@ -0,0 +1,55 @@ +package com.example.petclinic.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import com.example.petclinic.ui.screen.AddOwnerScreen +import com.example.petclinic.ui.theme.PetClinicTheme + +class AddOwnerFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + PetClinicTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.9f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + ) { + AddOwnerScreen( + onNavigateBack = { + parentFragmentManager.popBackStack() + } + ) + } + } + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BaseFragment.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BaseFragment.kt new file mode 100644 index 0000000..7808ccf --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BaseFragment.kt @@ -0,0 +1,36 @@ +package com.example.petclinic.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import com.example.petclinic.ui.theme.PetClinicTheme + +/** + * Base Fragment class that provides common functionality for all fragments + */ +abstract class BaseFragment : Fragment() { + + /** + * Override this to provide the Compose content for the fragment + */ + @Composable + abstract fun FragmentContent() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + PetClinicTheme { + FragmentContent() + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BottomNavigationFragment.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BottomNavigationFragment.kt new file mode 100644 index 0000000..15f6cda --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/fragment/BottomNavigationFragment.kt @@ -0,0 +1,109 @@ +package com.example.petclinic.ui.fragment + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import com.example.petclinic.MainActivity +import com.example.petclinic.OwnersActivity +import com.example.petclinic.VetsActivity +import com.example.petclinic.ui.components.BottomNavigationBar +import com.example.petclinic.ui.theme.PetClinicTheme + +/** + * Fragment that contains the bottom navigation bar + */ +class BottomNavigationFragment : Fragment() { + + private var selectedRoute: String = "home" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView") + // Get the selected route from arguments + selectedRoute = arguments?.getString(ARG_SELECTED_ROUTE) ?: "home" + + return ComposeView(requireContext()).apply { + setContent { + PetClinicTheme { + BottomNavigationBar( + selectedRoute = selectedRoute, + onNavigate = { destination -> + navigateToActivity(destination) + } + ) + } + } + } + } + + override fun onStart() { + super.onStart() + Log.d(TAG, "onStart") + } + + override fun onResume() { + super.onResume() + Log.d(TAG, "onResume") + } + + override fun onPause() { + super.onPause() + Log.d(TAG, "onPause") + } + + override fun onStop() { + super.onStop() + Log.d(TAG, "onStop") + } + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "onDestroy") + } + + /** + * Navigate to the appropriate activity based on the destination route + */ + private fun navigateToActivity(route: String) { + val intent = when (route) { + "home" -> Intent(requireContext(), MainActivity::class.java) + "owners" -> Intent(requireContext(), OwnersActivity::class.java) + "vets" -> Intent(requireContext(), VetsActivity::class.java) + else -> return + } + + // Don't start the same activity we're already in + if (intent.component?.className != requireActivity()::class.java.name) { + startActivity(intent) + } + } + + companion object { + private const val TAG = "BottomNavFragment" + private const val ARG_SELECTED_ROUTE = "selected_route" + + /** + * Create a new instance of BottomNavigationFragment with the specified selected route + */ + fun newInstance(selectedRoute: String): BottomNavigationFragment { + return BottomNavigationFragment().apply { + arguments = Bundle().apply { + putString(ARG_SELECTED_ROUTE, selectedRoute) + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/AddOwnerScreen.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/AddOwnerScreen.kt new file mode 100644 index 0000000..1aff095 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/AddOwnerScreen.kt @@ -0,0 +1,181 @@ +package com.example.petclinic.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.petclinic.data.model.OwnerRequest +import com.example.petclinic.ui.viewmodel.OwnersViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddOwnerScreen( + onNavigateBack: () -> Unit, + viewModel: OwnersViewModel = viewModel() +) { + var firstName by remember { mutableStateOf("") } + var lastName by remember { mutableStateOf("") } + var address by remember { mutableStateOf("") } + var city by remember { mutableStateOf("") } + var telephone by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + // Top App Bar + TopAppBar( + title = { Text("Add New Owner") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Owner Information", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + // First Name + OutlinedTextField( + value = firstName, + onValueChange = { firstName = it }, + label = { Text("First Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + // Last Name + OutlinedTextField( + value = lastName, + onValueChange = { lastName = it }, + label = { Text("Last Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + // Address + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text("Address") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + // City + OutlinedTextField( + value = city, + onValueChange = { city = it }, + label = { Text("City") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + // Telephone + OutlinedTextField( + value = telephone, + onValueChange = { telephone = it }, + label = { Text("Telephone") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isLoading + ) + + // Error message + errorMessage?.let { error -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Submit button + Button( + onClick = { + if (firstName.isBlank() || lastName.isBlank() || address.isBlank() || + city.isBlank() || telephone.isBlank()) { + errorMessage = "Please fill in all fields" + return@Button + } + + isLoading = true + errorMessage = null + + val ownerRequest = OwnerRequest( + firstName = firstName.trim(), + lastName = lastName.trim(), + address = address.trim(), + city = city.trim(), + telephone = telephone.trim() + ) + + viewModel.addOwner( + ownerRequest = ownerRequest, + onSuccess = { + isLoading = false + onNavigateBack() + }, + onError = { error -> + isLoading = false + errorMessage = error + } + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + if (isLoading) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Text("Adding Owner...") + } + } else { + Text("Add Owner") + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/HomeScreen.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/HomeScreen.kt new file mode 100644 index 0000000..59ca930 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/HomeScreen.kt @@ -0,0 +1,300 @@ +package com.example.petclinic.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.petclinic.data.network.ApiConfig +import kotlinx.coroutines.delay + +@Composable +fun HomeScreen() { + val scrollState = rememberScrollState() + var isJanking by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Welcome to Pet Clinic", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Application Signals Demo App", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("${ApiConfig.getBaseUrl()}/images/pets.png") + .crossfade(true) + .build(), + contentDescription = "Pet Clinic Pets", + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "About This App", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "This Pet Clinic mobile app demonstrates AWS Application Signals capabilities. " + + "You can manage pet owners, view veterinarians, and track pet visits - all while " + + "Application Signals monitors the performance and health of the underlying microservices.", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 24.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Features:", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val features = listOf( + "• Browse and search pet owners", + "• View owner details and pet information", + "• Add new owners and pets", + "• View veterinarian information", + "• Track pet visits and medical history" + ) + + features.forEach { feature -> + Text( + text = feature, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Testing Buttons Section + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "⚠️ Testing & Monitoring", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "These buttons demonstrate different types of issues for Application Signals monitoring:", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onErrorContainer, + lineHeight = 20.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Crash Button + Button( + onClick = { + throw RuntimeException("Intentional crash for Application Signals testing") + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Red, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text("💥 Trigger App Crash") + } + + Spacer(modifier = Modifier.height(8.dp)) + + // ANR Button + Button( + onClick = { + // Block the main thread to cause ANR + Thread.sleep(10000) // 10 seconds - will cause ANR + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF6B00), // Orange + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text("⏰ Trigger ANR (10s block)") + } + + Spacer(modifier = Modifier.height(8.dp)) + + // UI Jank Button + Button( + onClick = { + isJanking = !isJanking + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isJanking) Color(0xFF4CAF50) else Color(0xFFFF9800), // Green when active, Orange when inactive + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text(if (isJanking) "✅ Stop UI Jank" else "🐌 Start UI Jank") + } + + Spacer(modifier = Modifier.height(8.dp)) + + // HTTP 500 Error Button + Button( + onClick = { + Thread { + try { + val client = okhttp3.OkHttpClient() + val request = okhttp3.Request.Builder() + .url("https://httpbin.org/status/500") + .build() + client.newCall(request).execute() + } catch (e: Exception) { + // Ignore + } + }.start() + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF9C27B0), // Purple + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text("🌐 HTTP 500 Error") + } + + if (isJanking) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "UI Jank is active - causing slow rendering...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Medium + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Jank-inducing component when active + if (isJanking) { + JankInducingComponent() + } + } +} + +@Composable +fun JankInducingComponent() { + // This component will cause UI jank by doing expensive operations on the main thread + LaunchedEffect(Unit) { + while (true) { + // Simulate expensive computation on main thread + val startTime = System.currentTimeMillis() + while (System.currentTimeMillis() - startTime < 100) { + // Busy wait for 100ms - this will cause frame drops + Math.sqrt(Math.random() * 1000000) + } + delay(50) // Small delay before next jank + } + } + + // Visual indicator that jank is happening + Card( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Yellow.copy(alpha = 0.3f) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = Color.Red, + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Causing UI Jank...", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Red + ) + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnerDetailScreen.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnerDetailScreen.kt new file mode 100644 index 0000000..491a047 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnerDetailScreen.kt @@ -0,0 +1,333 @@ +package com.example.petclinic.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.petclinic.data.model.Owner +import com.example.petclinic.data.model.Pet +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.ui.components.AddPetDialog +import com.example.petclinic.ui.components.EditOwnerDialog +import com.example.petclinic.ui.viewmodel.OwnerDetailViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OwnerDetailScreen( + ownerId: Int, + onNavigateBack: () -> Unit, + viewModel: OwnerDetailViewModel = viewModel() +) { + var showEditDialog by remember { mutableStateOf(false) } + var showAddPetDialog by remember { mutableStateOf(false) } + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var isUpdating by remember { mutableStateOf(false) } + + LaunchedEffect(ownerId) { + viewModel.loadOwnerWithVisits(ownerId) + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + // Top App Bar + TopAppBar( + title = { Text("Owner Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showEditDialog = true }) { + Icon(Icons.Default.Edit, contentDescription = "Edit Owner") + } + } + ) + + // Content + when (val state = viewModel.ownerState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading owner details...") + } + } + } + is ApiResult.Success -> { + val owner = state.data + LazyColumn( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + OwnerInfoCard(owner = owner) + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Pets (${owner.pets.size})", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + FloatingActionButton( + onClick = { showAddPetDialog = true }, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Pet") + } + } + } + + if (owner.pets.isEmpty()) { + item { + Text( + text = "No pets registered for this owner", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } + } else { + items(owner.pets) { pet -> + PetCard( + pet = pet, + nutritionFacts = viewModel.nutritionFactsMap[pet.id] ?: "", + isLoadingNutrition = viewModel.nutritionLoadingMap[pet.id] ?: false + ) + } + } + } + } + is ApiResult.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Error Loading Owner", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = state.exception.message ?: "Unknown error", + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { viewModel.loadOwnerWithVisits(ownerId) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Retry") + } + } + } + } + } + null -> { + // Initial state + } + } + } + + // Edit Owner Dialog + if (showEditDialog && viewModel.ownerState is ApiResult.Success) { + EditOwnerDialog( + owner = (viewModel.ownerState as ApiResult.Success).data, + onDismiss = { + if (!isUpdating) { + showEditDialog = false + } + }, + onSave = { ownerRequest -> + isUpdating = true + viewModel.updateOwner( + ownerId = ownerId, + ownerRequest = ownerRequest, + onSuccess = { + isUpdating = false + showEditDialog = false + }, + onError = { error -> + isUpdating = false + errorMessage = error + showErrorDialog = true + } + ) + }, + isLoading = isUpdating + ) + } + + // Add Pet Dialog + if (showAddPetDialog) { + AddPetDialog( + petTypes = (viewModel.petTypesState as? ApiResult.Success)?.data ?: emptyList(), + onDismiss = { showAddPetDialog = false }, + onSave = { petRequest -> + viewModel.addPet( + ownerId = ownerId, + petRequest = petRequest, + onSuccess = { showAddPetDialog = false }, + onError = { /* Handle error */ } + ) + } + ) + } + + // Error Dialog + if (showErrorDialog) { + AlertDialog( + onDismissRequest = { showErrorDialog = false }, + title = { Text("Update Failed") }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = { showErrorDialog = false }) { + Text("OK") + } + } + ) + } +} + +@Composable +fun OwnerInfoCard(owner: Owner) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "${owner.firstName} ${owner.lastName}", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Address: ${owner.address}", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "City: ${owner.city}", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Phone: ${owner.telephone}", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun PetCard( + pet: Pet, + nutritionFacts: String = "", + isLoadingNutrition: Boolean = false +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = pet.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Type: ${pet.type.name.replaceFirstChar { it.uppercase() }}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Birth Date: ${pet.birthDate}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (pet.visits.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Recent Visits: ${pet.visits.size}", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.secondary + ) + } + + // Nutrition Facts Section + if (isLoadingNutrition) { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading nutrition...", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (nutritionFacts.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Nutrition Facts", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary + ) + Text( + text = nutritionFacts, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnersScreen.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnersScreen.kt new file mode 100644 index 0000000..7a72274 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/OwnersScreen.kt @@ -0,0 +1,198 @@ +package com.example.petclinic.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.petclinic.data.model.Owner +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.ui.viewmodel.OwnersViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OwnersScreen( + onOwnerClick: (Int) -> Unit, + onAddOwnerClick: () -> Unit, + viewModel: OwnersViewModel = viewModel(), + refreshTrigger: Int = 0 +) { + // Refresh owners list when refreshTrigger changes (when activity resumes) + LaunchedEffect(refreshTrigger) { + viewModel.loadOwners() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Pet Owners", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + FloatingActionButton( + onClick = onAddOwnerClick, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Owner") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Search bar + OutlinedTextField( + value = viewModel.searchQuery, + onValueChange = viewModel::updateSearchQuery, + label = { Text("Search owners...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Content + when (val state = viewModel.ownersState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading owners...") + } + } + } + is ApiResult.Success -> { + if (viewModel.filteredOwners.isEmpty()) { + if (viewModel.searchQuery.isBlank()) { + Text( + text = "No owners found", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } else { + Text( + text = "No owners match your search", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(viewModel.filteredOwners) { owner -> + OwnerCard( + owner = owner, + onClick = { onOwnerClick(owner.id) } + ) + } + } + } + } + is ApiResult.Error -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Error Loading Owners", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = state.exception.message ?: "Unknown error", + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = viewModel::loadOwners, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Retry") + } + } + } + } + null -> { + // Initial state - should not happen as we load in init + } + } + } +} + +@Composable +fun OwnerCard( + owner: Owner, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "${owner.firstName} ${owner.lastName}", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = owner.address, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = owner.city, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Phone: ${owner.telephone}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (owner.pets.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Pets: ${owner.pets.size}", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/VetsScreen.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/VetsScreen.kt new file mode 100644 index 0000000..9e339f9 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/screen/VetsScreen.kt @@ -0,0 +1,149 @@ +package com.example.petclinic.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.petclinic.data.model.Vet +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.ui.viewmodel.VetsViewModel + +@Composable +fun VetsScreen( + viewModel: VetsViewModel = viewModel() +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header + Text( + text = "Veterinarians", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Content + when (val state = viewModel.vetsState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading veterinarians...") + } + } + } + is ApiResult.Success -> { + val vets = state.data + if (vets.isEmpty()) { + Text( + text = "No veterinarians found", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(vets) { vet -> + VetCard(vet = vet) + } + } + } + } + is ApiResult.Error -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Error Loading Veterinarians", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = state.exception.message ?: "Unknown error", + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = viewModel::loadVets, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Retry") + } + } + } + } + null -> { + // Initial state - should not happen as we load in init + } + } + } +} + +@Composable +fun VetCard(vet: Vet) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Dr. ${vet.firstName} ${vet.lastName}", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + if (vet.specialties.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Specialties:", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + vet.specialties.forEach { specialty -> + Text( + text = "• ${specialty.name.replaceFirstChar { it.uppercase() }}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(start = 8.dp) + ) + } + } else { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "General Practice", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Color.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Color.kt new file mode 100644 index 0000000..866dc55 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package com.example.petclinic.ui.theme + +import androidx.compose.ui.graphics.Color + +// Custom Pet Clinic Colors +val PetClinicBackground = Color(0xFFF1F1F1) +val PetClinicSecondary = Color(0xFF34302D) +val PetClinicHighlight = Color(0xFF6DB33F) + +// Dark theme variants +val PetClinicBackgroundDark = Color(0xFF1C1C1C) +val PetClinicSecondaryDark = Color(0xFF4A453F) +val PetClinicHighlightDark = Color(0xFF8BC34A) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Theme.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Theme.kt new file mode 100644 index 0000000..395300c --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Theme.kt @@ -0,0 +1,71 @@ +package com.example.petclinic.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = PetClinicHighlightDark, + secondary = PetClinicSecondaryDark, + background = PetClinicBackgroundDark, + surface = PetClinicBackgroundDark, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color.White, + onSurface = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = PetClinicHighlight, + secondary = PetClinicSecondary, + background = PetClinicBackground, + surface = PetClinicBackground, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = PetClinicSecondary, + onSurface = PetClinicSecondary +) + +@Composable +fun PetClinicTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, // Disabled to use custom colors + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Type.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Type.kt new file mode 100644 index 0000000..8eded6e --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.petclinic.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnerDetailViewModel.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnerDetailViewModel.kt new file mode 100644 index 0000000..f19ead1 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnerDetailViewModel.kt @@ -0,0 +1,148 @@ +package com.example.petclinic.ui.viewmodel + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.petclinic.data.model.Owner +import com.example.petclinic.data.model.OwnerRequest +import com.example.petclinic.data.model.Pet +import com.example.petclinic.data.model.PetRequest +import com.example.petclinic.data.model.PetType +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.data.repository.PetClinicRepository +import kotlinx.coroutines.launch + +class OwnerDetailViewModel( + private val repository: PetClinicRepository = PetClinicRepository() +) : ViewModel() { + + companion object { + private const val TAG = "OwnerDetailViewModel" + } + + var ownerState by mutableStateOf?>(null) + private set + + var petTypesState by mutableStateOf>?>(null) + private set + + var nutritionLoadingMap by mutableStateOf>(emptyMap()) + private set + + var nutritionFactsMap by mutableStateOf>(emptyMap()) + private set + + init { + loadPetTypes() + } + + fun loadOwnerWithVisits(ownerId: Int) { + viewModelScope.launch { + repository.getOwnerWithVisits(ownerId).collect { result -> + ownerState = result + // Load nutrition for each pet + if (result is ApiResult.Success) { + loadNutritionForPets(result.data.pets) + } + } + } + } + + private fun loadNutritionForPets(pets: List) { + pets.forEach { pet -> + viewModelScope.launch { + nutritionLoadingMap = nutritionLoadingMap + (pet.id to true) + Log.d(TAG, "Loading nutrition for pet ${pet.id}, type: ${pet.type.name}") + when (val result = repository.getPetNutrition(pet.type.name)) { + + is ApiResult.Success -> { + android.os.Handler(android.os.Looper.getMainLooper()).post { + Log.d(TAG, "Nutrition loaded for pet ${pet.id}: ${result.data.facts}") + // Parsing logic assumes facts always have at least 3 lines + // Line 0: Calories info + // Line 1: Protein info + // Line 2: Vitamins info + val lines = result.data.facts.split("\n") + val caloriesInfo = lines[0] + val proteinInfo = lines[1] + val vitaminsInfo = lines[2] + + Log.d(TAG, "Parsed nutrition - Calories: $caloriesInfo, Protein: $proteinInfo, Vitamins: $vitaminsInfo") + + nutritionFactsMap = nutritionFactsMap + (pet.id to result.data.facts) + nutritionLoadingMap = nutritionLoadingMap + (pet.id to false) + } + } + is ApiResult.Error -> { + Log.e(TAG, "Failed to load nutrition for pet ${pet.id}: ${result.exception.message}") + nutritionLoadingMap = nutritionLoadingMap + (pet.id to false) + } + is ApiResult.Loading -> { + Log.d(TAG, "Nutrition loading for pet ${pet.id}") + } + } + } + } + } + + private fun loadPetTypes() { + viewModelScope.launch { + repository.getPetTypes().collect { result -> + petTypesState = result + } + } + } + + fun updateOwner( + ownerId: Int, + ownerRequest: OwnerRequest, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + Log.d(TAG, "Updating owner $ownerId with data: $ownerRequest") + when (val result = repository.updateOwner(ownerId, ownerRequest)) { + is ApiResult.Success -> { + Log.d(TAG, "Owner $ownerId updated successfully") + onSuccess() + loadOwnerWithVisits(ownerId) // Refresh the owner data + } + is ApiResult.Error -> { + Log.e(TAG, "Failed to update owner $ownerId: ${result.exception.message}", result.exception) + onError(result.exception.message ?: "Unknown error") + } + is ApiResult.Loading -> { + Log.d(TAG, "Update owner $ownerId - loading state") + } + } + } + } + + fun addPet( + ownerId: Int, + petRequest: PetRequest, + onSuccess: (Pet) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + Log.d(TAG, "Adding pet for owner $ownerId: $petRequest") + when (val result = repository.addPet(ownerId, petRequest)) { + is ApiResult.Success -> { + Log.d(TAG, "Pet added successfully for owner $ownerId") + onSuccess(result.data) + loadOwnerWithVisits(ownerId) // Refresh the owner data + } + is ApiResult.Error -> { + Log.e(TAG, "Failed to add pet for owner $ownerId: ${result.exception.message}", result.exception) + onError(result.exception.message ?: "Unknown error") + } + is ApiResult.Loading -> { + Log.d(TAG, "Add pet for owner $ownerId - loading state") + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnersViewModel.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnersViewModel.kt new file mode 100644 index 0000000..3c83839 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/OwnersViewModel.kt @@ -0,0 +1,73 @@ +package com.example.petclinic.ui.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.petclinic.data.model.Owner +import com.example.petclinic.data.model.OwnerRequest +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.data.repository.PetClinicRepository +import kotlinx.coroutines.launch + +class OwnersViewModel( + private val repository: PetClinicRepository = PetClinicRepository() +) : ViewModel() { + + var ownersState by mutableStateOf>?>(null) + private set + + var searchQuery by mutableStateOf("") + private set + + var filteredOwners by mutableStateOf>(emptyList()) + private set + + fun loadOwners() { + viewModelScope.launch { + repository.getOwners().collect { result -> + ownersState = result + if (result is ApiResult.Success) { + filterOwners(result.data, searchQuery) + } + } + } + } + + fun updateSearchQuery(query: String) { + searchQuery = query + val currentOwners = (ownersState as? ApiResult.Success)?.data ?: emptyList() + filterOwners(currentOwners, query) + } + + private fun filterOwners(owners: List, query: String) { + filteredOwners = if (query.isBlank()) { + owners + } else { + owners.filter { owner -> + "${owner.firstName} ${owner.lastName}".contains(query, ignoreCase = true) || + owner.address.contains(query, ignoreCase = true) || + owner.city.contains(query, ignoreCase = true) || + owner.telephone.contains(query, ignoreCase = true) + } + } + } + + fun addOwner(ownerRequest: OwnerRequest, onSuccess: () -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + when (val result = repository.addOwner(ownerRequest)) { + is ApiResult.Success -> { + onSuccess() + loadOwners() // Refresh the list + } + is ApiResult.Error -> { + onError(result.exception.message ?: "Unknown error") + } + is ApiResult.Loading -> { + // Handle loading state if needed + } + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/VetsViewModel.kt b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/VetsViewModel.kt new file mode 100644 index 0000000..bfdc4f8 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/java/com/example/petclinic/ui/viewmodel/VetsViewModel.kt @@ -0,0 +1,31 @@ +package com.example.petclinic.ui.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.petclinic.data.model.Vet +import com.example.petclinic.data.network.ApiResult +import com.example.petclinic.data.repository.PetClinicRepository +import kotlinx.coroutines.launch + +class VetsViewModel( + private val repository: PetClinicRepository = PetClinicRepository() +) : ViewModel() { + + var vetsState by mutableStateOf>?>(null) + private set + + init { + loadVets() + } + + fun loadVets() { + viewModelScope.launch { + repository.getVets().collect { result -> + vetsState = result + } + } + } +} diff --git a/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_background.xml b/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..2e0a7f7 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_foreground.xml b/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7d08537 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/demo-apps/PetClinic-Android/src/main/res/raw/aws_config.json b/demo-apps/PetClinic-Android/src/main/res/raw/aws_config.json new file mode 100644 index 0000000..71f285d --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/raw/aws_config.json @@ -0,0 +1,10 @@ +{ + "aws": { + "region": "", + "rumAppMonitorId": "" + }, + "otelResourceAttributes": { + "service.name": "PetClinic-AndroidApp-Prod", + "service.version": "1.0.0" + } +} \ No newline at end of file diff --git a/demo-apps/PetClinic-Android/src/main/res/values-night/themes.xml b/demo-apps/PetClinic-Android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..affeeca --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/values-night/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/demo-apps/PetClinic-Android/src/main/res/values/strings.xml b/demo-apps/PetClinic-Android/src/main/res/values/strings.xml new file mode 100644 index 0000000..e2f912a --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Pet Clinic + diff --git a/demo-apps/PetClinic-Android/src/main/res/values/themes.xml b/demo-apps/PetClinic-Android/src/main/res/values/themes.xml new file mode 100644 index 0000000..ddcf7b6 --- /dev/null +++ b/demo-apps/PetClinic-Android/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + +