diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 5da77bc..6b5e871 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -12,12 +12,25 @@ on: tag: required: true type: string + upload_to_testflight: + required: false + default: true + type: boolean + submit_for_review: + required: false + default: false + type: boolean workflow_dispatch: inputs: upload_to_testflight: description: 'Upload to TestFlight' required: false - default: true + default: false + type: boolean + submit_for_review: + description: 'Submit this exact version for App Review (requires upload)' + required: false + default: false type: boolean permissions: @@ -25,7 +38,7 @@ permissions: jobs: build: - name: Build & Submit to App Store + name: Build iOS runs-on: macos-26 timeout-minutes: 60 steps: @@ -167,7 +180,7 @@ jobs: uploadSymbols destination - upload + export EOF @@ -194,12 +207,33 @@ jobs: -authenticationKeyIssuerID "$ISSUER_ID" \ -allowProvisioningUpdates - echo "✅ IPA exported and uploaded to App Store Connect" + echo "✅ IPA exported" + + - name: Save signed IPA + uses: actions/upload-artifact@v4 + with: + name: emerkit-ios-${{ steps.version.outputs.version }} + path: build/ios/ipa/*.ipa + if-no-files-found: error + retention-days: 7 + + - name: Upload to TestFlight + id: upload + if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || inputs.upload_to_testflight == true + env: + KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + run: | + xcrun altool --upload-app --type ios \ + --file "$(find build/ios/ipa -name '*.ipa' -print -quit)" \ + --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID" - name: Install Python dependencies for App Store Connect API + if: inputs.submit_for_review == true && steps.upload.outcome == 'success' run: pip3 install --break-system-packages PyJWT requests - name: Submit to App Review + if: inputs.submit_for_review == true && steps.upload.outcome == 'success' env: KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} @@ -286,22 +320,25 @@ jobs: r.raise_for_status() builds = r.json()["data"] if not builds: - print("⚠️ Build not found yet in App Store Connect, skipping auto-submit") - sys.exit(0) + raise RuntimeError("Exact build not found in App Store Connect") build_id = builds[0]["id"] processing = builds[0]["attributes"].get("processingState", "unknown") + if processing != "VALID": + raise RuntimeError("Build is not VALID; refusing App Review submission") print(f"Build ID: {build_id}, processing: {processing}") # Get or create the app store version r = requests.get( - f"{base}/apps/{app_id}/appStoreVersions?filter[appStoreState]=READY_FOR_SALE,PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW,IN_REVIEW,DEVELOPER_REJECTED&limit=1", + f"{base}/apps/{app_id}/appStoreVersions?filter[versionString]={version}&filter[platform]=IOS&limit=10", headers=headers ) r.raise_for_status() - versions = r.json()["data"] + versions = [v for v in r.json()["data"] if v["attributes"]["versionString"] == version] + if versions and versions[0]["attributes"]["appStoreState"] not in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"): + raise RuntimeError("The exact version is not editable; refusing to alter another version") - if versions and versions[0]["attributes"]["appStoreState"] == "PREPARE_FOR_SUBMISSION": + if versions: version_id = versions[0]["id"] print(f"Using existing version: {version_id}") else: @@ -324,7 +361,7 @@ jobs: if r.ok: print(f"✅ Build {build_id} attached to version {version_id}") else: - print(f"⚠️ Could not attach build: {r.status_code} {r.text}") + r.raise_for_status() # Submit for review r = requests.post(f"{base}/appStoreVersionSubmissions", headers=headers, json={ @@ -338,8 +375,7 @@ jobs: if r.ok: print(f"✅ Submitted version {version} for App Review!") else: - print(f"⚠️ Could not submit for review: {r.status_code} {r.text}") - print("You may need to submit manually from App Store Connect") + r.raise_for_status() PYEOF - name: Cleanup keychain diff --git a/.github/workflows/capture-ios-store-assets.yml b/.github/workflows/capture-ios-store-assets.yml new file mode 100644 index 0000000..69b2055 --- /dev/null +++ b/.github/workflows/capture-ios-store-assets.yml @@ -0,0 +1,155 @@ +name: Capture iOS Store Assets + +on: + workflow_dispatch: + inputs: + device: + description: 'Device class to capture' + type: choice + required: true + default: all + options: [all, iphone, ipad] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + capture: + name: Capture ${{ matrix.device }} + runs-on: macos-26 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + device: ${{ fromJSON(inputs.device == 'ipad' && '["ipad"]' || inputs.device == 'iphone' && '["iphone"]' || '["iphone", "ipad"]') }} + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + cache: true + - name: Install dependencies + run: flutter pub get + - name: Select and boot Simulator + id: simulator + env: + DEVICE_CLASS: ${{ matrix.device }} + run: | + xcrun simctl list devices available -j > "$RUNNER_TEMP/devices.json" + python3 <<'PY' + import json, os, re + from pathlib import Path + devices = json.loads(Path(os.environ['RUNNER_TEMP'], 'devices.json').read_text())['devices'] + pattern = r'iPhone .*Pro Max' if os.environ['DEVICE_CLASS'] == 'iphone' else r'iPad Pro.*13-inch' + candidates = [(runtime, d) for runtime, group in devices.items() for d in group + if d.get('isAvailable') and re.search(pattern, d['name'])] + if not candidates: + raise SystemExit(f'No supported {os.environ["DEVICE_CLASS"]} Simulator found') + runtime, device = sorted(candidates, key=lambda pair: (pair[0], pair[1]['name']), reverse=True)[0] + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'udid={device["udid"]}\n') + Path('store-captures').mkdir(exist_ok=True) + metadata = {'device': device['name'], 'runtime': runtime, 'udid': device['udid'], + 'commit': os.environ['GITHUB_SHA'], 'version': json.loads(Path('version.json').read_text())['version'], + 'source': 'iOS app running in Apple Simulator; native simctl capture'} + Path('store-captures/08-capture-metadata.json').write_text(json.dumps(metadata, indent=2)) + PY + UDID=$(python3 -c 'import json; print(json.load(open("store-captures/08-capture-metadata.json"))["udid"])') + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + xcrun simctl ui "$UDID" appearance light + xcrun simctl status_bar "$UDID" override --time '9:41' --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100 + - name: Build Simulator app + timeout-minutes: 15 + run: flutter build ios --simulator --debug --no-pub --target test_driver/store_app.dart + - name: Run app and capture six screens + timeout-minutes: 5 + env: + CAPTURE_UDID: ${{ steps.simulator.outputs.udid }} + CAPTURE_OUTPUT: ${{ github.workspace }}/store-captures + CAPTURE_VIDEO: ${{ matrix.device == 'iphone' }} + run: | + xcrun simctl install "$CAPTURE_UDID" build/ios/iphonesimulator/Runner.app + # Connect to a known loopback port instead of waiting for Simulator logs. + xcrun simctl launch --terminate-running-process "$CAPTURE_UDID" online.globalemergency.emerkit \ + --vm-service-port=54321 --disable-service-auth-codes --disable-vm-service-publication + export VM_SERVICE_URL='http://127.0.0.1:54321/' + python3 <<'PY' + import json, time, urllib.request, urllib.error + for attempt in range(60): + try: + with urllib.request.urlopen('http://127.0.0.1:54321/getVM', timeout=2) as response: + assert json.load(response)['result']['type'] == 'VM' + print('Simulator VM Service ready') + break + except (OSError, ValueError, KeyError): + time.sleep(1) + else: + raise SystemExit('Simulator VM Service did not start within 180 seconds') + PY + dart run test_driver/store_capture.dart + - name: Export iPhone App Preview + if: matrix.device == 'iphone' + run: | + brew install ffmpeg + ffmpeg -y -i store-captures/07-preview-raw.mov \ + -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \ + -map 0:v:0 -map 1:a:0 -t 20 -r 30 \ + -vf 'scale=886:1920:force_original_aspect_ratio=decrease,pad=886:1920:(ow-iw)/2:(oh-ih)/2,setsar=1' \ + -c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p \ + -b:v 10M -maxrate 12M -bufsize 20M \ + -c:a aac -b:a 256k -ac 2 -ar 48000 -movflags +faststart \ + store-captures/07-app-preview-iphone.mp4 + - name: Verify captured images and video + env: + DEVICE_CLASS: ${{ matrix.device }} + run: | + python3 <<'PY' + import json, os, struct, subprocess + from pathlib import Path + folder = Path('store-captures') + expected = ['01-home', '02-glasgow', '03-rcp', '04-triage', '05-ecg', '06-home-scroll'] + dimensions = set() + for name in expected: + data = (folder / f'{name}.png').read_bytes() + assert data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) > 10000, name + dimensions.add(struct.unpack('>II', data[16:24])) + assert len(dimensions) == 1, dimensions + width, height = dimensions.pop() + assert width >= 1242 and height > width, (width, height) + metadata_path = folder / '08-capture-metadata.json' + metadata = json.loads(metadata_path.read_text()) + metadata['screenshotSize'] = [width, height] + if os.environ['DEVICE_CLASS'] == 'iphone': + video = folder / '07-app-preview-iphone.mp4' + probe = json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', str(video)])) + stream = next(s for s in probe['streams'] if s['codec_type'] == 'video') + audio = next(s for s in probe['streams'] if s['codec_type'] == 'audio') + assert stream['codec_name'] == 'h264' and [stream['width'], stream['height']] == [886, 1920] + assert stream['r_frame_rate'] == '30/1' + assert audio['codec_name'] == 'aac' and audio['channels'] == 2 + assert 19.9 <= float(probe['format']['duration']) <= 20.1 + assert video.stat().st_size < 500_000_000 + metadata['appPreview'] = {'durationSeconds': 20, 'size': [886, 1920], 'fps': 30} + metadata_path.write_text(json.dumps(metadata, indent=2)) + PY + - name: Capture diagnostics after failure + if: failure() && steps.simulator.outputs.udid != '' + run: | + xcrun simctl io '${{ steps.simulator.outputs.udid }}' screenshot store-captures/99-failure.png || true + xcrun simctl spawn '${{ steps.simulator.outputs.udid }}' log show --last 3m --style compact --predicate 'process == "Runner"' > store-captures/99-runner.log || true + - name: Save captures and original footage + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-store-${{ matrix.device }}-${{ github.sha }} + path: store-captures/ + if-no-files-found: error + retention-days: 14 + - name: Shutdown Simulator + if: always() && steps.simulator.outputs.udid != '' + run: xcrun simctl shutdown '${{ steps.simulator.outputs.udid }}' diff --git a/.github/workflows/publish-store-listing.yml b/.github/workflows/publish-store-listing.yml new file mode 100644 index 0000000..a7dfb60 --- /dev/null +++ b/.github/workflows/publish-store-listing.yml @@ -0,0 +1,40 @@ +name: Publish EmerKit store listing + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: emerkit-google-store-listing + cancel-in-progress: false + +jobs: + publish: + # Publishing is an explicit operation, separate from code review and iOS capture. + if: github.repository == 'GlobalEmergency/EmerKit' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install Google Play client + run: python -m pip install google-api-python-client google-auth + - name: Upload authorized metadata and screenshots + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: python store-assets/2026-10/16-publish-google.py + - name: Preserve before state and result + if: always() + uses: actions/upload-artifact@v4 + with: + name: emerkit-google-store-result + path: | + store-assets/2026-10/18-google-*.json + store-assets/2026-10/19-google-upload-result.json + if-no-files-found: ignore diff --git a/assets/version.json b/assets/version.json index 6f9087e..624b67f 100644 --- a/assets/version.json +++ b/assets/version.json @@ -1 +1 @@ -{"version": "0.1.17"} +{"version": "0.1.18"} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e3e59a1..81b6e2c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -186,10 +186,9 @@ }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; + developmentRegion = es; hasScannedForEncodings = 0; knownRegions = ( - en, es, Base, ); diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d6c9ad9..f19a3fe 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,11 @@ CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + es + CFBundleLocalizations + + es + CFBundleDisplayName EmerKit CFBundleExecutable diff --git a/pubspec.yaml b/pubspec.yaml index 532ba3a..f0ee598 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: emerkit description: EmerKit - Herramientas clinicas para profesionales de emergencias publish_to: 'none' -version: 0.1.17+117 +version: 0.1.18+118 environment: sdk: ^3.5.0 @@ -21,6 +21,8 @@ dependency_overrides: path_provider_foundation: 2.4.0 dev_dependencies: + flutter_driver: + sdk: flutter flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/store-assets/2026-10/16-publish-google.py b/store-assets/2026-10/16-publish-google.py new file mode 100644 index 0000000..7816293 --- /dev/null +++ b/store-assets/2026-10/16-publish-google.py @@ -0,0 +1,64 @@ +"""Publish the authorized EmerKit listing using the repository's protected secret. + +All replacements happen in one Google Play edit; any failure discards that edit. +No releases, tracks, binaries, privacy answers or access permissions are changed. +""" +import hashlib +import json +import os +from pathlib import Path +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload + +BASE=Path(__file__).resolve().parent +PACKAGE='online.globalemergency.emerkit' + + +def publish(): + metadata=json.loads((BASE/'17-metadata-to-publish.json').read_text(encoding='utf-8')) + for locale in ['es-ES','en-US']: + fields=metadata['googlePlay']['localizations'][locale] + assert len(fields['title'])<=30 and len(fields['shortDescription'])<=80 and len(fields['fullDescription'])<=4000 + folder=BASE/('es' if locale=='es-ES' else 'en') + assert len(list((folder/'android-phone').glob('*.png')))==6 + assert (folder/'banners/01-feature-dark.png').is_file() + creds=service_account.Credentials.from_service_account_info(json.loads(os.environ['GOOGLE_PLAY_SERVICE_ACCOUNT_JSON']),scopes=['https://www.googleapis.com/auth/androidpublisher']) + service=build('androidpublisher','v3',credentials=creds,cache_discovery=False) + edits=service.edits() + edit=edits.insert(packageName=PACKAGE,body={}).execute()['id'] + committed=False + try: + previous=edits.listings().list(packageName=PACKAGE,editId=edit).execute() + (BASE/'18-google-before-upload.json').write_text(json.dumps(previous,ensure_ascii=False,indent=2),encoding='utf-8') + existing={x['language']:x for x in previous.get('listings',[])} + results=[] + for locale in ['es-ES','en-US']: + folder=BASE/('es' if locale=='es-ES' else 'en') + fields=metadata['googlePlay']['localizations'][locale] + body={'language':locale,**{k:fields[k] for k in ['title','shortDescription','fullDescription']}} + if existing.get(locale,{}).get('video'): body['video']=existing[locale]['video'] + edits.listings().update(packageName=PACKAGE,editId=edit,language=locale,body=body).execute() + for image_type,files in [('phoneScreenshots',sorted((folder/'android-phone').glob('*.png'))),('featureGraphic',[folder/'banners/01-feature-dark.png'])]: + old=edits.images().list(packageName=PACKAGE,editId=edit,language=locale,imageType=image_type).execute() + (BASE/f'18-google-{locale}-{image_type}-before.json').write_text(json.dumps(old,indent=2),encoding='utf-8') + edits.images().deleteall(packageName=PACKAGE,editId=edit,language=locale,imageType=image_type).execute() + for source in files: + edits.images().upload(packageName=PACKAGE,editId=edit,language=locale,imageType=image_type,media_body=MediaFileUpload(str(source),mimetype='image/png')).execute() + after=edits.images().list(packageName=PACKAGE,editId=edit,language=locale,imageType=image_type).execute() + assert len(after.get('images',[]))==len(files), image_type + results.append({'locale':locale,'type':image_type,'count':len(files)}) + actual=edits.listings().get(packageName=PACKAGE,editId=edit,language=locale).execute() + assert all(actual[k]==fields[k] for k in ['title','shortDescription','fullDescription']) + edits.validate(packageName=PACKAGE,editId=edit).execute() + result=edits.commit(packageName=PACKAGE,editId=edit,changesNotSentForReview=False).execute() + committed=True + report={'package':PACKAGE,'state':'COMMITTED_REVIEW_REQUESTED','result':result,'resources':results,'metadataSha256':hashlib.sha256((BASE/'17-metadata-to-publish.json').read_bytes()).hexdigest()} + (BASE/'19-google-upload-result.json').write_text(json.dumps(report,indent=2),encoding='utf-8') + print(json.dumps(report)) + finally: + if not committed: edits.delete(packageName=PACKAGE,editId=edit).execute() + + +if __name__=='__main__': + publish() diff --git a/store-assets/2026-10/17-metadata-to-publish.json b/store-assets/2026-10/17-metadata-to-publish.json new file mode 100644 index 0000000..c097519 --- /dev/null +++ b/store-assets/2026-10/17-metadata-to-publish.json @@ -0,0 +1,126 @@ +{ + "status": "AUTHORIZED_FOR_UPLOAD_2026_10_24", + "preparedAt": "2026-10-24", + "packageName": "online.globalemergency.emerkit", + "reviewRequired": [ + "Contrastar las funcionalidades descritas con la build concreta antes de enviar: el código local puede ser posterior a la versión publicada.", + "Verificar las URLs de privacidad actuales y su contenido; se conservan distintas sin dar por válida ninguna.", + "Completar las novedades solo después de revisar los cambios reales de la build. No copiar los borradores como texto final.", + "Confirmar categoría y localizaciones actuales en las consolas antes de aplicar cambios; no se han modificado las stores." + ], + "languages": { + "listingDefault": "es-ES", + "listingLocalizations": [ + "es-ES", + "en-US" + ], + "productInterfaceLanguages": [ + "es" + ], + "productLanguageEvidence": "Código de main y checkout inspeccionado: interfaz española. Se mantienen fichas ES/EN; usuario autoriza pantallas españolas temporalmente. No se anuncia traducción de interfaz inglesa sin build verificada.", + "iosBinaryLanguagePreparation": "03-ios-localization.patch propone CFBundleLocalizations=[es,en]. No está aplicado. Verificar los recursos y las traducciones de la build elegida antes de aplicarlo y comprobar los idiomas detectados por App Store Connect después de procesarla.", + "localCodeLocalizationStatus": "El checkout revisado contiene nombres de herramientas en español y lib/app.dart no declara supportedLocales ni localizationsDelegates. Esta diferencia no invalida la confirmación del usuario sobre el producto: comparar con la rama o el paquete publicado antes de preparar una build." + }, + "googlePlay": { + "applicationType": "APPLICATION", + "category": "MEDICAL", + "categoryRationale": "Las escalas, calculadoras y referencias para profesionales sanitarios corresponden a Medical. La categoría Tools describe utilidades del dispositivo y no la finalidad principal de EmerKit.", + "defaultLanguage": "es-ES", + "privacyPolicyUrl": "https://www.globalemergency.online/privacidad-emerkit", + "privacyPolicyVerification": "PENDING_KEEP_CURRENT_URL", + "localizations": { + "es-ES": { + "title": "EmerKit: RCP y escalas", + "shortDescription": "Escalas clínicas, RCP y calculadoras offline para TES, enfermería y medicina", + "fullDescription": "Tu kit de emergencias sanitarias en el bolsillo. Escalas clínicas, calculadoras y guías para TES, enfermería y medicina. Gratis, sin anuncios y de código abierto, con bibliografía para ampliar la consulta.\n\nConsulta las calculadoras y guías incorporadas sin conexión.\n\nVALORACIÓN Y REFERENCIA\nConsulta Glasgow, triage START, Triángulo de Evaluación Pediátrica, Cincinnati, Madrid-DIRECT, NIHSS y Rankin. Accede también a rangos de constantes vitales, clasificación de glucemia y temperatura, y cálculo del índice de shock.\n\nSOPORTE VITAL Y OXIGENOTERAPIA\nUtiliza el metrónomo de RCP y consulta los esquemas de soporte vital básico. Calcula la autonomía estimada de una botella de oxígeno a partir de los datos introducidos y consulta dispositivos de oxigenoterapia.\n\nTÉCNICAS Y COMUNICACIÓN\nRevisa la colocación de electrodos ECG, las estimaciones de superficie quemada mediante Wallace y Lund-Browder, y las guías de vendajes, heridas y posiciones de traslado. Incluye cálculo de diluciones, referencias sobre EPI y mercancías peligrosas ADR, comunicación SBAR y un glosario.\n\nUTILIDADES\nBusca herramientas por su nombre y convierte coordenadas GPS. La opción DEA Cercano abre DeaMap, un servicio externo para localizar desfibriladores.\n\nCONEXIÓN Y UBICACIÓN\nDeaMap, los enlaces, la consulta de direcciones y los mapas externos pueden requerir internet; obtener la ubicación necesita permiso y servicios de localización del dispositivo.\n\nUSO PROFESIONAL\nEmerKit es una herramienta de apoyo y referencia; no sustituye la formación profesional, los protocolos oficiales ni el juicio clínico. Su uso es responsabilidad del profesional sanitario." + }, + "en-US": { + "title": "EmerKit: EMT & Paramedic Tools", + "shortDescription": "Offline clinical scales, CPR and calculators for emergency healthcare teams", + "fullDescription": "Your pocket emergency medical toolkit. Clinical scales, calculators and guides for emergency medical technicians, nurses and physicians. Free, ad-free and open source, with bibliographic references for further reading.\n\nUse the built-in calculators and guides offline.\n\nASSESSMENT AND REFERENCE\nConsult Glasgow, START triage, the Pediatric Assessment Triangle, Cincinnati, Madrid-DIRECT, NIHSS and Rankin. Find vital-sign reference ranges, blood glucose and temperature classifications, and a shock index calculator.\n\nLIFE SUPPORT AND OXYGEN THERAPY\nUse the CPR metronome and consult basic life support flowcharts. Estimate oxygen cylinder duration from the values you enter and review oxygen delivery devices.\n\nTECHNIQUES AND COMMUNICATION\nReview ECG electrode placement, burn surface area estimates using Wallace and Lund-Browder, and guides to bandaging, wounds and patient transport positions. Includes dilution calculations, PPE and ADR dangerous goods references, SBAR communication and a glossary.\n\nUTILITIES\nSearch for tools by name and convert GPS coordinates. The nearby AED option opens DeaMap, an external defibrillator location service.\n\nCONNECTIVITY AND LOCATION\nDeaMap, external links, address lookups and external maps may require internet; getting your location requires permission and the device's location services.\n\nPROFESSIONAL USE\nEmerKit is a support and reference tool; it does not replace professional training, official protocols or clinical judgment. Healthcare professionals are responsible for its use." + } + }, + "releaseNotesDraft": { + "status": "BLOCKED_UNTIL_BUILD_CHANGELOG_IS_REVIEWED", + "es-ES": "BORRADOR: completar únicamente con los cambios comprobados de la compilación que se enviará.", + "en-US": "DRAFT: replace with the verified changes in the build being submitted." + } + }, + "appStore": { + "primaryCategory": "MEDICAL", + "secondaryCategory": "EDUCATION", + "categoryRationale": "Medical representa la referencia sanitaria; Education se mantiene como secundaria por las guías, referencias y uso formativo.", + "primaryLocale": "es-ES", + "privacyPolicyUrl": "https://www.globalemergency.online/privacidad-emerkit", + "privacyPolicyVerification": "HTTP_200_CONTENT_VERIFIED_2026_10_24", + "localizations": { + "es-ES": { + "name": "EmerKit: RCP y escalas", + "subtitle": "TES, enfermería y medicina", + "keywords": "glasgow,nihss,oxígeno,triage,ictus,sbar,quemaduras,diluciones,ambulancia,emergencias,triaje,ecg,tep", + "promotionalText": "Gratis, sin anuncios y de código abierto. Escalas clínicas, calculadoras offline y bibliografía para profesionales de emergencias.", + "description": "Tu kit de emergencias sanitarias en el bolsillo. Escalas clínicas, calculadoras y guías para TES, enfermería y medicina. Gratis, sin anuncios y de código abierto, con bibliografía para ampliar la consulta.\n\nConsulta las calculadoras y guías incorporadas sin conexión.\n\nVALORACIÓN Y REFERENCIA\nConsulta Glasgow, triage START, Triángulo de Evaluación Pediátrica, Cincinnati, Madrid-DIRECT, NIHSS y Rankin. Accede también a rangos de constantes vitales, clasificación de glucemia y temperatura, y cálculo del índice de shock.\n\nSOPORTE VITAL Y OXIGENOTERAPIA\nUtiliza el metrónomo de RCP y consulta los esquemas de soporte vital básico. Calcula la autonomía estimada de una botella de oxígeno a partir de los datos introducidos y consulta dispositivos de oxigenoterapia.\n\nTÉCNICAS Y COMUNICACIÓN\nRevisa la colocación de electrodos ECG, las estimaciones de superficie quemada mediante Wallace y Lund-Browder, y las guías de vendajes, heridas y posiciones de traslado. Incluye cálculo de diluciones, referencias sobre EPI y mercancías peligrosas ADR, comunicación SBAR y un glosario.\n\nUTILIDADES\nBusca herramientas por su nombre y convierte coordenadas GPS. La opción DEA Cercano abre DeaMap, un servicio externo para localizar desfibriladores.\n\nCONEXIÓN Y UBICACIÓN\nDeaMap, los enlaces, la consulta de direcciones y los mapas externos pueden requerir internet; obtener la ubicación necesita permiso y servicios de localización del dispositivo.\n\nUSO PROFESIONAL\nEmerKit es una herramienta de apoyo y referencia; no sustituye la formación profesional, los protocolos oficiales ni el juicio clínico. Su uso es responsabilidad del profesional sanitario.", + "whatsNew": "Corregimos la declaración del idioma de la app en iOS y renovamos las capturas y la información de la ficha.", + "whatsNewDraft": "BORRADOR: completar únicamente con los cambios comprobados de la compilación que se enviará.", + "supportUrl": "https://www.globalemergency.online/proyectos/emerkit", + "marketingUrl": "https://www.globalemergency.online/proyectos/emerkit" + }, + "en-US": { + "name": "EmerKit: EMT & Paramedic Tools", + "subtitle": "CPR, clinical scales & oxygen", + "keywords": "glasgow,nihss,triage,stroke,nursing,burns,dilution,sbar,ambulance,emergency,offline,ecg,reference", + "promotionalText": "Free, ad-free and open source. Offline clinical scales, calculators and references for emergency healthcare professionals.", + "description": "Your pocket emergency medical toolkit. Clinical scales, calculators and guides for emergency medical technicians, nurses and physicians. Free, ad-free and open source, with bibliographic references for further reading.\n\nUse the built-in calculators and guides offline.\n\nASSESSMENT AND REFERENCE\nConsult Glasgow, START triage, the Pediatric Assessment Triangle, Cincinnati, Madrid-DIRECT, NIHSS and Rankin. Find vital-sign reference ranges, blood glucose and temperature classifications, and a shock index calculator.\n\nLIFE SUPPORT AND OXYGEN THERAPY\nUse the CPR metronome and consult basic life support flowcharts. Estimate oxygen cylinder duration from the values you enter and review oxygen delivery devices.\n\nTECHNIQUES AND COMMUNICATION\nReview ECG electrode placement, burn surface area estimates using Wallace and Lund-Browder, and guides to bandaging, wounds and patient transport positions. Includes dilution calculations, PPE and ADR dangerous goods references, SBAR communication and a glossary.\n\nUTILITIES\nSearch for tools by name and convert GPS coordinates. The nearby AED option opens DeaMap, an external defibrillator location service.\n\nCONNECTIVITY AND LOCATION\nDeaMap, external links, address lookups and external maps may require internet; getting your location requires permission and the device's location services.\n\nPROFESSIONAL USE\nEmerKit is a support and reference tool; it does not replace professional training, official protocols or clinical judgment. Healthcare professionals are responsible for its use.", + "whatsNew": "Corrected the app language declaration on iOS and refreshed the screenshots and store information.", + "whatsNewDraft": "DRAFT: replace with the verified changes in the build being submitted.", + "supportUrl": "https://www.globalemergency.online/proyectos/emerkit", + "marketingUrl": "https://www.globalemergency.online/proyectos/emerkit" + } + } + }, + "verifiedCodeEvidence": [ + "lib/app.dart: configuración Flutter sin delegados ni lista de idiomas.", + "lib/features/home/presentation/tool_registry.dart: herramientas y nombres en español; DeaMap externo.", + "lib/features/home/presentation/home_screen.dart: búsqueda y apertura de DeaMap.", + "lib/shared/utils/external_app_launcher.dart: apertura de aplicación externa y URL alternativa.", + "lib/features/gps_converter/presentation/gps_converter_screen.dart: permisos, posición, geocodificación y aplicaciones externas de mapas.", + "lib/features/rcp/presentation/rcp_screen.dart: metrónomo; se evita citar una frecuencia o una versión publicada.", + "lib/shared/presentation/widgets/tool_info_panel.dart: información y referencias de las herramientas.", + "README.md y LICENSE: propósito profesional, uso offline de herramientas incorporadas y licencia MIT; bibliografía visible en ToolInfoPanel. Gratuidad y ausencia de anuncios contrastadas en la ficha revisada del producto." + ], + "officialSources": [ + { + "topic": "Google Play: límites 30/80/4000", + "url": "https://support.google.com/googleplay/android-developer/answer/9859152" + }, + { + "topic": "Google Play: Medical y Tools", + "url": "https://support.google.com/googleplay/android-developer/answer/9859673" + }, + { + "topic": "Apple: nombre, subtítulo e idioma principal de la ficha", + "url": "https://developer.apple.com/help/app-store-connect/reference/app-information/app-information/" + }, + { + "topic": "Apple: descripción 4000, keywords 100 bytes UTF-8, promoción 170 y novedades 4000", + "url": "https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information/" + }, + { + "topic": "Apple: Medical y Education", + "url": "https://developer.apple.com/app-store/categories/" + }, + { + "topic": "Apple: idiomas del binario frente a localizaciones de la ficha", + "url": "https://developer.apple.com/library/archive/qa/qa1828/_index.html" + }, + { + "topic": "Apple: declaración de localizaciones gestionadas por la app", + "url": "https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundlelocalizations" + }, + { + "topic": "Google Play: recomendaciones de descripción corta sin información de precio ni puntuación final innecesaria", + "url": "https://support.google.com/googleplay/android-developer/answer/9866151" + } + ], + "targetIosVersion": "0.1.18" +} diff --git a/store-assets/2026-10/en/android-phone/01-escalas.png b/store-assets/2026-10/en/android-phone/01-escalas.png new file mode 100644 index 0000000..b14348c Binary files /dev/null and b/store-assets/2026-10/en/android-phone/01-escalas.png differ diff --git a/store-assets/2026-10/en/android-phone/01-escalas.svg b/store-assets/2026-10/en/android-phone/01-escalas.svg new file mode 100644 index 0000000..7056f2e --- /dev/null +++ b/store-assets/2026-10/en/android-phone/01-escalas.svg @@ -0,0 +1 @@ +EmerKitCLINICAL REFERENCEYour scales.Always at hand.Glasgow, NIHSS and clinical reference.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/android-phone/02-offline.png b/store-assets/2026-10/en/android-phone/02-offline.png new file mode 100644 index 0000000..cb4ad97 Binary files /dev/null and b/store-assets/2026-10/en/android-phone/02-offline.png differ diff --git a/store-assets/2026-10/en/android-phone/02-offline.svg b/store-assets/2026-10/en/android-phone/02-offline.svg new file mode 100644 index 0000000..a5a6f3d --- /dev/null +++ b/store-assets/2026-10/en/android-phone/02-offline.svg @@ -0,0 +1 @@ +EmerKitWHERE YOU NEED ITNo signal.Still equipped.Offline scales and calculators.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/android-phone/03-rcp.png b/store-assets/2026-10/en/android-phone/03-rcp.png new file mode 100644 index 0000000..3fdce5b Binary files /dev/null and b/store-assets/2026-10/en/android-phone/03-rcp.png differ diff --git a/store-assets/2026-10/en/android-phone/03-rcp.svg b/store-assets/2026-10/en/android-phone/03-rcp.svg new file mode 100644 index 0000000..11a336b --- /dev/null +++ b/store-assets/2026-10/en/android-phone/03-rcp.svg @@ -0,0 +1 @@ +EmerKitLIFE SUPPORTYour CPR rhythm.One tap away.Metronome with clear controls.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/android-phone/04-triaje.png b/store-assets/2026-10/en/android-phone/04-triaje.png new file mode 100644 index 0000000..c512345 Binary files /dev/null and b/store-assets/2026-10/en/android-phone/04-triaje.png differ diff --git a/store-assets/2026-10/en/android-phone/04-triaje.svg b/store-assets/2026-10/en/android-phone/04-triaje.svg new file mode 100644 index 0000000..f4c009d --- /dev/null +++ b/store-assets/2026-10/en/android-phone/04-triaje.svg @@ -0,0 +1 @@ +EmerKitASSESSMENTSTART triage.Step by step.Follow the flow in the app.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/android-phone/05-ecg.png b/store-assets/2026-10/en/android-phone/05-ecg.png new file mode 100644 index 0000000..f3166ea Binary files /dev/null and b/store-assets/2026-10/en/android-phone/05-ecg.png differ diff --git a/store-assets/2026-10/en/android-phone/05-ecg.svg b/store-assets/2026-10/en/android-phone/05-ecg.svg new file mode 100644 index 0000000..6902bb7 --- /dev/null +++ b/store-assets/2026-10/en/android-phone/05-ecg.svg @@ -0,0 +1 @@ +EmerKitTECHNIQUESReview ECGlead placement.Visual reference for your practice.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/android-phone/06-herramientas.png b/store-assets/2026-10/en/android-phone/06-herramientas.png new file mode 100644 index 0000000..787c335 Binary files /dev/null and b/store-assets/2026-10/en/android-phone/06-herramientas.png differ diff --git a/store-assets/2026-10/en/android-phone/06-herramientas.svg b/store-assets/2026-10/en/android-phone/06-herramientas.svg new file mode 100644 index 0000000..acb099e --- /dev/null +++ b/store-assets/2026-10/en/android-phone/06-herramientas.svg @@ -0,0 +1 @@ +EmerKitYOUR EMERGENCY TOOLKITFrom referenceto revision.Tools and references in one app.For EMTs, nurses and physicians.ES / EN · No ads \ No newline at end of file diff --git a/store-assets/2026-10/en/banners/01-feature-dark.png b/store-assets/2026-10/en/banners/01-feature-dark.png new file mode 100644 index 0000000..6b25088 Binary files /dev/null and b/store-assets/2026-10/en/banners/01-feature-dark.png differ diff --git a/store-assets/2026-10/es/android-phone/01-escalas.png b/store-assets/2026-10/es/android-phone/01-escalas.png new file mode 100644 index 0000000..3380296 Binary files /dev/null and b/store-assets/2026-10/es/android-phone/01-escalas.png differ diff --git a/store-assets/2026-10/es/android-phone/01-escalas.svg b/store-assets/2026-10/es/android-phone/01-escalas.svg new file mode 100644 index 0000000..fa6774a --- /dev/null +++ b/store-assets/2026-10/es/android-phone/01-escalas.svg @@ -0,0 +1 @@ +EmerKitCONSULTA CLÍNICATus escalas.Siempre a mano.Glasgow, NIHSS y valoración clínica.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/android-phone/02-offline.png b/store-assets/2026-10/es/android-phone/02-offline.png new file mode 100644 index 0000000..e0eda2a Binary files /dev/null and b/store-assets/2026-10/es/android-phone/02-offline.png differ diff --git a/store-assets/2026-10/es/android-phone/02-offline.svg b/store-assets/2026-10/es/android-phone/02-offline.svg new file mode 100644 index 0000000..7914f73 --- /dev/null +++ b/store-assets/2026-10/es/android-phone/02-offline.svg @@ -0,0 +1 @@ +EmerKitDONDE HACE FALTASin cobertura.Con recursos.Escalas y calculadoras sin conexión.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/android-phone/03-rcp.png b/store-assets/2026-10/es/android-phone/03-rcp.png new file mode 100644 index 0000000..bde303c Binary files /dev/null and b/store-assets/2026-10/es/android-phone/03-rcp.png differ diff --git a/store-assets/2026-10/es/android-phone/03-rcp.svg b/store-assets/2026-10/es/android-phone/03-rcp.svg new file mode 100644 index 0000000..8ed4085 --- /dev/null +++ b/store-assets/2026-10/es/android-phone/03-rcp.svg @@ -0,0 +1 @@ +EmerKitSOPORTE VITALEl ritmo RCP,a un toque.Metrónomo con controles visibles.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/android-phone/04-triaje.png b/store-assets/2026-10/es/android-phone/04-triaje.png new file mode 100644 index 0000000..655ec5b Binary files /dev/null and b/store-assets/2026-10/es/android-phone/04-triaje.png differ diff --git a/store-assets/2026-10/es/android-phone/04-triaje.svg b/store-assets/2026-10/es/android-phone/04-triaje.svg new file mode 100644 index 0000000..aaba39b --- /dev/null +++ b/store-assets/2026-10/es/android-phone/04-triaje.svg @@ -0,0 +1 @@ +EmerKitVALORACIÓNTriaje START.Paso a paso.Consulta el flujo desde la app.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/android-phone/05-ecg.png b/store-assets/2026-10/es/android-phone/05-ecg.png new file mode 100644 index 0000000..2946e65 Binary files /dev/null and b/store-assets/2026-10/es/android-phone/05-ecg.png differ diff --git a/store-assets/2026-10/es/android-phone/05-ecg.svg b/store-assets/2026-10/es/android-phone/05-ecg.svg new file mode 100644 index 0000000..e2975a9 --- /dev/null +++ b/store-assets/2026-10/es/android-phone/05-ecg.svg @@ -0,0 +1 @@ +EmerKitTÉCNICASRepasa lacolocación ECG.Consulta visual para tu práctica.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/android-phone/06-herramientas.png b/store-assets/2026-10/es/android-phone/06-herramientas.png new file mode 100644 index 0000000..5000e1a Binary files /dev/null and b/store-assets/2026-10/es/android-phone/06-herramientas.png differ diff --git a/store-assets/2026-10/es/android-phone/06-herramientas.svg b/store-assets/2026-10/es/android-phone/06-herramientas.svg new file mode 100644 index 0000000..4a1fb08 --- /dev/null +++ b/store-assets/2026-10/es/android-phone/06-herramientas.svg @@ -0,0 +1 @@ +EmerKitTU KIT DE EMERGENCIASDe la consultaal repaso.Herramientas y referencias en una app.Para TES, enfermería y medicina.ES / EN · Sin anuncios \ No newline at end of file diff --git a/store-assets/2026-10/es/banners/01-feature-dark.png b/store-assets/2026-10/es/banners/01-feature-dark.png new file mode 100644 index 0000000..76cefd5 Binary files /dev/null and b/store-assets/2026-10/es/banners/01-feature-dark.png differ diff --git a/test_driver/store_app.dart b/test_driver/store_app.dart new file mode 100644 index 0000000..f62ffd8 --- /dev/null +++ b/test_driver/store_app.dart @@ -0,0 +1,7 @@ +import 'package:flutter_driver/driver_extension.dart'; +import 'package:emerkit/main.dart' as app; + +void main() { + enableFlutterDriverExtension(); + app.main(); +} diff --git a/test_driver/store_capture.dart b/test_driver/store_capture.dart new file mode 100644 index 0000000..f7a3b07 --- /dev/null +++ b/test_driver/store_capture.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_driver/flutter_driver.dart'; + +Future main() async { + final udid = Platform.environment['CAPTURE_UDID']; + final output = Platform.environment['CAPTURE_OUTPUT']; + if (udid == null || output == null) { + throw StateError('CAPTURE_UDID and CAPTURE_OUTPUT are required'); + } + await Directory(output).create(recursive: true); + final driver = await FlutterDriver.connect().timeout( + const Duration(seconds: 90), + ); + Process? recording; + Future? recordingComplete; + + Future native(List args) async { + final result = await Process.run('xcrun', ['simctl', ...args]).timeout( + const Duration(seconds: 30), + ); + if (result.exitCode != 0) { + throw StateError('simctl failed: ${result.stderr}'); + } + } + + Future capture(String name, String marker) async { + stderr.writeln('Capture $name: waiting for $marker'); + await driver.waitFor(find.text(marker), + timeout: const Duration(seconds: 30)); + await Future.delayed(const Duration(milliseconds: 700)); + await native(['io', udid, 'screenshot', '$output/$name.png']); + if (await File('$output/$name.png').length() < 10000) { + throw StateError('Capture $name is empty'); + } + } + + Future openTool(String label, String title) async { + await driver.tap(find.byType('TextField'), + timeout: const Duration(seconds: 30)); + stderr.writeln('Open tool: $label'); + await driver.enterText(label, timeout: const Duration(seconds: 30)); + await driver.tap( + find.descendant( + of: find.byType('ToolCard'), + matching: find.text(label), + ), + timeout: const Duration(seconds: 30)); + await driver.waitFor(find.text(title), + timeout: const Duration(seconds: 30)); + } + + Future home() async { + stderr.writeln('Return to home'); + await driver.tap(find.pageBack(), timeout: const Duration(seconds: 30)); + await driver.waitFor(find.byType('TextField'), + timeout: const Duration(seconds: 30)); + await driver.tap(find.byType('TextField'), + timeout: const Duration(seconds: 30)); + await driver.enterText('', timeout: const Duration(seconds: 30)); + } + + try { + await driver.waitFor(find.byType('TextField'), + timeout: const Duration(seconds: 30)); + await capture('01-home', 'Buscar herramienta...'); + if (Platform.environment['CAPTURE_VIDEO'] == 'true') { + recording = await Process.start('xcrun', [ + 'simctl', + 'io', + udid, + 'recordVideo', + '--codec=h264', + '$output/07-preview-raw.mov', + ]); + unawaited(recording.stdout.drain()); + unawaited(recording.stderr.drain()); + final process = recording; + recordingComplete = () async { + // Keep two seconds of margin before the final exact 20-second export. + await Future.delayed(const Duration(seconds: 22)); + process.kill(ProcessSignal.sigint); + final exitCode = await process.exitCode; + if (exitCode != 0) throw StateError('Simulator recording failed'); + }(); + await Future.delayed(const Duration(seconds: 2)); + } + await openTool('Glasgow', 'Escala de Glasgow'); + await capture('02-glasgow', 'Escala de Glasgow'); + await Future.delayed(const Duration(seconds: 2)); + await home(); + await openTool('RCP', 'RCP'); + await capture('03-rcp', 'RCP'); + await Future.delayed(const Duration(seconds: 2)); + await home(); + await openTool('Triage START', 'Triage START'); + await capture('04-triage', 'Triage START'); + await home(); + await openTool('Electrodos ECG', 'Electrodos ECG'); + await capture('05-ecg', 'Electrodos ECG'); + await home(); + await driver.scrollUntilVisible( + find.byType('ListView'), + find.text('Vendajes'), + dyScroll: -300, + alignment: 0.35, + timeout: const Duration(seconds: 30), + ); + await capture('06-home-scroll', 'Vendajes'); + await recordingComplete; + } finally { + recording?.kill(ProcessSignal.sigint); + await driver.close(); + } +} diff --git a/version.json b/version.json index 6f9087e..624b67f 100644 --- a/version.json +++ b/version.json @@ -1 +1 @@ -{"version": "0.1.17"} +{"version": "0.1.18"}