From 001f394038f5c042660c1c91380ca1eefaff5d54 Mon Sep 17 00:00:00 2001 From: ialakey Date: Sun, 6 Sep 2026 16:13:17 +0200 Subject: [PATCH 1/4] release: build, sign and verify the Play artifacts locally The last release was cut by hand in January 2024 and nothing recorded how. This adds the missing half of the pipeline: - tool/build_release.ps1 mirrors release.yml, so a local build and a tagged CI build produce the same artifact under the same checks. It refuses to spend a build on a missing key.properties, a key that is not SHA256withRSA, or a versionCode Play has already seen, then verifies the APK signature scheme, the bundle signature and the merged targetSdk before copying both artifacts into dist/ with their sums. - tool/create_upload_key.ps1 and tool/show_upload_key.ps1 for the key itself. - versionName and versionCode now come from `version:` in pubspec.yaml, which build.gradle.kts already reads, so there is one place to bump. - docs/RELEASE.md walks through a release end to end. --- .github/workflows/release.yml | 14 +- .gitignore | 3 + android/.gitignore | 3 + android/app/build.gradle.kts | 6 +- docs/RELEASE.md | 143 +++++++++++++-- pubspec.yaml | 5 +- tool/build_release.ps1 | 323 ++++++++++++++++++++++++++++++++++ tool/create_upload_key.ps1 | 148 ++++++++++++++++ tool/release_common.ps1 | 181 +++++++++++++++++++ tool/show_upload_key.ps1 | 113 ++++++++++++ 10 files changed, 918 insertions(+), 21 deletions(-) create mode 100644 tool/build_release.ps1 create mode 100644 tool/create_upload_key.ps1 create mode 100644 tool/release_common.ps1 create mode 100644 tool/show_upload_key.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74f2c6e..cd0a432 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,8 +129,18 @@ jobs: - name: Verify App Bundle signature run: | - jarsigner -verify -strict -verbose:summary \ - build/app/outputs/bundle/release/app-release.aab | tail -20 + # No `-strict`, and no pipe. An app-signing certificate is self-signed + # by definition, which -strict counts as a severe warning and exits + # non-zero for; and piping into `tail` handed the step tail's exit + # code, so this check could never fail either way. + jarsigner -verify -verbose:summary \ + build/app/outputs/bundle/release/app-release.aab > /tmp/jarsig.txt + tail -20 /tmp/jarsig.txt + grep -q "jar verified" /tmp/jarsig.txt \ + || { echo "::error::jarsigner did not report the bundle as verified"; exit 1; } + if grep -qi "CN=Android Debug" /tmp/jarsig.txt; then + echo "::error::bundle was signed with the debug key"; exit 1 + fi - name: Report signing details run: | diff --git a/.gitignore b/.gitignore index 24476c5..a771f28 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Signed artifacts produced by tool/build_release.ps1 +/dist/ diff --git a/android/.gitignore b/android/.gitignore index 6f56801..4116c85 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# Kotlin Gradle plugin scratch dir (session locks, compiler error logs) +/.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 780fda7..edecb98 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,7 +34,11 @@ android { applicationId = "com.alakey.serbiaguide" minSdk = flutter.minSdkVersion targetSdk = 36 - versionCode = 11 + // Both come from `version:` in pubspec.yaml (`versionName+versionCode`) + // so there is a single place to bump. `flutter build` can override them + // with --build-name / --build-number; tool/build_release.ps1 always + // passes both explicitly. + versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 3fc1ac0..e3dd00b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,7 +1,30 @@ # Release & signing -The `Release` workflow builds a signed App Bundle and APK and refuses to publish -anything that is not signed with a real SHA-256 upload key. +Two ways to produce the same artifact under the same checks: + +| | | +|---|---| +| locally, on Windows | `.\tool\build_release.ps1` | +| in CI, on a `v*` tag | the `Release` workflow | + +Both refuse to publish anything that is not signed with a real SHA-256 upload +key. What Play asks for on the upload screen — the "What's new" text — is +in [play/](play/); the full account of the release is in +[CHANGELOG.md](CHANGELOG.md). + +## The short version + +```powershell +# once, ever +.\tool\create_upload_key.ps1 # creates the key, prints what Play asks for +.\tool\show_upload_key.ps1 # prints it again later, if you need it + +# bump `version:` in pubspec.yaml, then, for every release +.\tool\build_release.ps1 +``` + +The bundle lands in `dist/srbguide-+.aab`. Upload that; +`dist/srbguide-+.apk` is for sideloading and manual QA. ## 1. Create the upload key (once) @@ -9,6 +32,14 @@ Google Play requires RSA 2048+ and a validity that runs past 2033. `-sigalg SHA256withRSA` is what makes the certificate itself SHA-256; the workflow verifies this and fails if the key uses an older algorithm. +```powershell +.\tool\create_upload_key.ps1 +``` + +It prompts for a password, runs the `keytool` invocation below, checks the +certificate really is SHA-256, and writes `android/key.properties` for you. +`-PrintBase64` also dumps the value for the CI secret. + ```bash keytool -genkeypair \ -alias upload \ @@ -20,9 +51,34 @@ keytool -genkeypair \ -dname "CN=Ilia Alakov, O=Serbia Guide, C=RS" ``` -Keep `upload-keystore.jks` and its passwords somewhere safe and offline. If you -lose them you cannot ship an update to the existing listing — you would have to -ask Play support to reset the upload key. +Keep `upload-keystore.jks` and its passwords somewhere safe and offline. A +SHA-256 fingerprint is a hash of the certificate, so a fingerprint you wrote +down somewhere is not a spare copy of the key — nothing can be signed with it. + +## 1a. Registering the key with Play + +Play needs to know the public half of whichever key you sign uploads with. It +asks for it in one of two forms depending on the screen: + +```powershell +.\tool\show_upload_key.ps1 +``` + +That prints the **SHA-256 fingerprint** to paste into "Добавьте открытый ключ, +указав его цифровой отпечаток сертификата SHA-256", and writes a **PEM +certificate** next to the keystore for the screens that take a file instead. +Both are public; the keystore and its password never leave the machine. + +This matters because the app signing key and the upload key are different +things. Under Play App Signing, Google holds the app signing key that end users +verify, and it re-signs every release — so replacing the upload key does not +break updates for anyone who already has the app installed. The upload key only +proves that an upload came from you. Losing it is recoverable; losing the app +signing key would not be, and you never had it. + +Compare what Play shows under **App integrity → App signing → Upload key +certificate** with what `show_upload_key.ps1` prints. If they differ, the build +will be rejected at upload with "the APK was signed with the wrong key". ## 2. Add the repository secrets @@ -39,30 +95,65 @@ On macOS use `base64 -i upload-keystore.jks | tr -d '\n'`. ## 3. Building locally -The same key can be used locally through `android/key.properties`, which is -git-ignored: +`tool/build_release.ps1` does everything the workflow does. It resolves the +Flutter SDK, JDK and Android build-tools by itself (SETUP.md records how they +are pinned on this machine), so no PATH setup is needed. + +```powershell +.\tool\build_release.ps1 # version from pubspec.yaml +.\tool\build_release.ps1 -VersionName 2.0.1 -BuildNumber 14 +.\tool\build_release.ps1 -BundleOnly # skip the APK +.\tool\build_release.ps1 -SkipChecks -SkipClean # fast rebuild, never for upload +``` + +It stops before spending a build on: + +- a missing or incomplete `android/key.properties`, or a keystore that is not + where that file says it is; +- an upload key that is not `SHA256withRSA`; +- a `versionCode` Play has already seen (`-PublishedBuildNumber`, currently + `12`; raise the default in the script after each accepted upload). + +Then it runs `flutter analyze`, the unit tests and `tool/validate_guide.dart`, +builds both artifacts at an explicit version, verifies the signatures, asserts +the merged manifest still targets SDK 36, and copies the results into `dist/` +with their SHA-256 sums. `dist/` is git-ignored. + +The key is read from `android/key.properties`, which is git-ignored: ```properties -storeFile=/absolute/path/to/upload-keystore.jks +storeFile=D:\\path\\to\\upload-keystore.jks storePassword=... keyAlias=upload keyPassword=... ``` -```bash -flutter build appbundle --release -``` +Gradle loads that file as a `java.util.Properties`, so on Windows the +backslashes have to be doubled; an absolute path avoids a second trap, since +Gradle resolves a relative one against `android/app`. +`create_upload_key.ps1` writes both correctly. Without that file the release build falls back to the debug signing config so `flutter run --release` still works. **A debug-signed bundle will be rejected by -Play** — the workflow fails the build rather than letting one through. +Play** — both the script and the workflow fail the build rather than letting +one through. ## 4. Cutting a release +`versionName` and `versionCode` both come from one line in `pubspec.yaml`: + +```yaml +version: 2.0.0+14 +``` + +`android/app/build.gradle.kts` reads them from there through +`flutter.versionCode` / `flutter.versionName`, and `build_release.ps1` passes +both to `flutter build` explicitly, so what ships is what the log printed. + ```bash -# bump `versionCode`/`versionName` in android/app/build.gradle.kts first -git tag v1.1.0 -git push origin v1.1.0 +# bump `version:` in pubspec.yaml first +git tag v2.0.0 +git push origin v2.0.0 ``` The tag triggers `Release`, which: @@ -73,7 +164,7 @@ The tag triggers `Release`, which: 4. runs `apksigner verify` and requires **APK Signature Scheme v2** plus a SHA-256 certificate digest, and fails if the artifact carries the Android debug certificate; -5. verifies the AAB with `jarsigner -verify -strict`; +5. verifies the AAB with `jarsigner -verify`; 6. asserts the merged manifest still has `targetSdkVersion="36"`; 7. uploads both artifacts and opens a **draft** GitHub Release. @@ -82,6 +173,24 @@ to compare against "Upload key certificate" in the Play Console. ## 5. Before uploading to Play -- `versionCode` must be higher than the published one (currently `11`). +- `versionCode` must be higher than the published one: production is on + `12` (1.0.0), published 21 January 2024. - Play requires `targetSdk` 36 (Android 16); step 6 above guards this. - `minSdk` is 24, so Android 5.x devices no longer receive updates. + +Upload `dist/srbguide-+.aab` under **Production → Create new +release**, and paste the release notes from: + +| Play language | File | +|---|---| +| Russian | `docs/play/whats-new-ru-RU.txt` | +| English | `docs/play/whats-new-en-US.txt` | + +Both fit Play's 500-character limit for the "What's new" field. The unabridged +version of the same release is [CHANGELOG.md](CHANGELOG.md), which is for the +repository rather than for the listing. + +The bundle is around 60 MB, which is not what anyone downloads: half of it is +the native debug symbols Play keeps for crash symbolication, and the rest +carries all three ABIs. Play delivers one ABI per device, so the install is far +smaller — the Play Console shows the real figure once the bundle is processed. diff --git a/pubspec.yaml b/pubspec.yaml index 4d0489f..3a08951 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,10 @@ description: A new Flutter project. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.0.0+1 +# versionName+versionCode for the Android build (android/app/build.gradle.kts +# reads both from here). versionCode 12 is what is published on Google Play +# (release 12 (1.0.0), 21 Jan 2024), so every upload has to raise it. +version: 2.0.0+14 environment: sdk: '>=3.5.0 <4.0.0' diff --git a/tool/build_release.ps1 b/tool/build_release.ps1 new file mode 100644 index 0000000..a924731 --- /dev/null +++ b/tool/build_release.ps1 @@ -0,0 +1,323 @@ +<# +.SYNOPSIS + Builds the signed App Bundle (for Google Play) and APK (for sideloading), + then proves the signature is what Play expects. + +.DESCRIPTION + Mirrors .github/workflows/release.yml so a local build and a tagged CI build + produce the same artifact under the same checks: + + 1. resolve the toolchain (Flutter, JDK, Android SDK build-tools); + 2. refuse to run without android/key.properties -- a debug-signed upload + is rejected by Play and cannot be upgraded to a real key afterwards; + 3. verify the upload key itself is SHA256withRSA; + 4. flutter pub get / analyze / test / validate the bundled guide; + 5. build the AAB and the APK at an explicit version; + 6. apksigner: APK Signature Scheme v2, a SHA-256 certificate digest, and + not the Android debug certificate; + 7. jarsigner -verify on the AAB; + 8. assert the merged manifest still targets SDK 36; + 9. copy both artifacts into dist/ under their version, with SHA-256 sums. + + Versions come from `version:` in pubspec.yaml unless overridden here. + +.EXAMPLE + .\tool\build_release.ps1 + # 2.0.0+14 straight from pubspec.yaml + +.EXAMPLE + .\tool\build_release.ps1 -VersionName 2.0.1 -BuildNumber 14 + +.EXAMPLE + .\tool\build_release.ps1 -SkipChecks -SkipClean + # fast rebuild while iterating; never for the build you actually upload +#> +[CmdletBinding()] +param( + # Play's versionName, e.g. 2.0.0. Defaults to pubspec.yaml. + [string]$VersionName, + # Play's versionCode. Must be higher than anything already uploaded. + [int]$BuildNumber = 0, + # The lowest versionCode Play will accept next. Bump after each upload. + [int]$PublishedBuildNumber = 12, + # Skip analyze/test/guide validation. Not for a build you will upload. + [switch]$SkipChecks, + # Reuse the previous build's intermediates. + [switch]$SkipClean, + # Build only the .aab (what Play wants) and skip the .apk. + [switch]$BundleOnly, + # Build only the .apk (sideloading, manual QA) and skip the .aab. + [switch]$ApkOnly +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +if ($BundleOnly -and $ApkOnly) { throw 'Pass at most one of -BundleOnly / -ApkOnly.' } +$buildBundle = -not $ApkOnly +$buildApk = -not $BundleOnly + +Push-Location $repoRoot +$started = Get-Date +$failure = $null +try { + +# ---------------------------------------------------------------- toolchain -- +Write-Section 'Toolchain' +$flutter = Resolve-FlutterExe -RepoRoot $repoRoot +$jdk = Resolve-JdkHome +$androidSdk = Resolve-AndroidSdk -RepoRoot $repoRoot +$apksigner = Resolve-ApkSigner -AndroidSdk $androidSdk +$dart = Join-Path (Split-Path -Parent $flutter) 'dart.bat' +$keytool = Join-Path $jdk 'bin\keytool.exe' +$jarsigner = Join-Path $jdk 'bin\jarsigner.exe' +foreach ($exe in @($dart, $keytool, $jarsigner)) { + if (-not (Test-Path $exe)) { throw "Not found: $exe" } +} +# apksigner.bat looks for java through JAVA_HOME and fails outright without it. +# Flutter is pinned to its own JDK (SETUP.md), which need not be on PATH, so +# hand the same one to the SDK tools for this process only. +$env:JAVA_HOME = $jdk +Write-Detail 'flutter' $flutter +Write-Detail 'jdk' $jdk +Write-Detail 'android sdk' $androidSdk +Write-Detail 'apksigner' $apksigner + +# ------------------------------------------------------------------ signing -- +Write-Section 'Signing key' +$keyPropsPath = Join-Path $repoRoot 'android\key.properties' +if (-not (Test-Path $keyPropsPath)) { + # Without this file build.gradle.kts falls back to the debug config on + # purpose, so that `flutter run --release` keeps working. That fallback must + # never reach Play. + throw @" +android/key.properties is missing, so a release build here would be signed with +the Android debug key and rejected by Play. + + First release ever: .\tool\create_upload_key.ps1 + Existing key: write android/key.properties as + + storeFile=D:\\path\\to\\upload-keystore.jks + storePassword=... + keyAlias=upload + keyPassword=... +"@ +} +$keyProps = Read-JavaProperties $keyPropsPath +foreach ($required in @('storeFile', 'storePassword', 'keyAlias', 'keyPassword')) { + if (-not $keyProps.ContainsKey($required) -or -not $keyProps[$required]) { + throw "android/key.properties has no value for '$required'." + } +} +$storeFile = $keyProps['storeFile'] +if (-not [IO.Path]::IsPathRooted($storeFile)) { + # Gradle resolves this one through the :app project, so a relative path is + # relative to android/app -- easy to get wrong. Accept it, but say so. + Write-Warn "storeFile is relative; Gradle resolves it against android/app. An absolute path is safer." + $storeFile = Join-Path (Join-Path $repoRoot 'android\app') $storeFile +} +if (-not (Test-Path $storeFile)) { throw "Keystore not found: $storeFile" } +Write-Detail 'keystore' $storeFile +Write-Detail 'alias' $keyProps['keyAlias'] + +$keyInfo = Invoke-Capture $keytool @( + '-list', '-v', + '-keystore', $storeFile, + '-alias', $keyProps['keyAlias'], + '-storepass', $keyProps['storePassword'] +) 'keytool -list' +if (-not ($keyInfo | Select-String -Pattern 'Signature algorithm name:\s*SHA256with')) { + throw 'The upload key is not SHA256withRSA. Regenerate it (see docs/RELEASE.md); Play will not accept it.' +} +$keyFingerprint = ($keyInfo | Select-String -Pattern '^\s+SHA256:\s*(\S+)' | Select-Object -First 1) +$keyFingerprintValue = '' +Write-Ok 'key certificate is SHA-256' +if ($keyFingerprint) { + $keyFingerprintValue = $keyFingerprint.Matches[0].Groups[1].Value + Write-Detail 'fingerprint' $keyFingerprintValue +} +$expiry = ($keyInfo | Select-String -Pattern 'until:' | Select-Object -First 1) +if ($expiry) { Write-Detail 'validity' $expiry.Line.Trim() } + +# ------------------------------------------------------------------ version -- +Write-Section 'Version' +$pubspecPath = Join-Path $repoRoot 'pubspec.yaml' +$pubspecVersion = (Select-String -Path $pubspecPath -Pattern '^version:\s*(\S+)\s*$' | + Select-Object -First 1) +if (-not $pubspecVersion) { throw 'No version: line in pubspec.yaml.' } +$pubspecValue = $pubspecVersion.Matches[0].Groups[1].Value +$parts = $pubspecValue.Split('+') +if (-not $VersionName) { $VersionName = $parts[0] } +if ($BuildNumber -le 0) { + if ($parts.Count -lt 2) { throw "pubspec version '$pubspecValue' has no +buildNumber; pass -BuildNumber." } + $BuildNumber = [int]$parts[1] +} +Write-Detail 'pubspec' $pubspecValue +Write-Detail 'versionName' $VersionName +Write-Detail 'versionCode' $BuildNumber +if ($BuildNumber -le $PublishedBuildNumber) { + # Play rejects a duplicate versionCode after the upload, several minutes in. + throw "versionCode $BuildNumber is not above $PublishedBuildNumber, which is already on Play. Bump the version: line in pubspec.yaml, or pass -PublishedBuildNumber if that number is stale." +} + +# ------------------------------------------------------------------- checks -- +if (-not $SkipClean) { + Write-Section 'Clean' + Invoke-Checked $flutter @('clean') 'flutter clean' +} + +Write-Section 'Dependencies' +Invoke-Checked $flutter @('pub', 'get') 'flutter pub get' + +if ($SkipChecks) { + Write-Section 'Checks' + Write-Warn 'skipped by -SkipChecks; do not upload this build' +} else { + Write-Section 'Analyze' + Invoke-Checked $flutter @('analyze') 'flutter analyze' + + Write-Section 'Test' + # `live` tests hit the real exchange-office sites; a third-party outage + # should not block a release. parsers.yml runs them daily instead. + Invoke-Checked $flutter @('test', '--exclude-tags', 'live') 'flutter test' + + Write-Section 'Guide content' + Invoke-Checked $dart @('run', 'tool/validate_guide.dart') 'validate_guide' +} + +$versionArgs = @('--release', "--build-name=$VersionName", "--build-number=$BuildNumber") +$aabPath = Join-Path $repoRoot 'build\app\outputs\bundle\release\app-release.aab' +$apkPath = Join-Path $repoRoot 'build\app\outputs\flutter-apk\app-release.apk' + +# -------------------------------------------------------------------- build -- +if ($buildBundle) { + Write-Section 'Build App Bundle' + Invoke-Checked $flutter (@('build', 'appbundle') + $versionArgs) 'flutter build appbundle' + if (-not (Test-Path $aabPath)) { throw "Expected $aabPath" } +} +if ($buildApk) { + Write-Section 'Build APK' + Invoke-Checked $flutter (@('build', 'apk') + $versionArgs) 'flutter build apk' + if (-not (Test-Path $apkPath)) { throw "Expected $apkPath" } +} + +# ------------------------------------------------------------------- verify -- +$certLines = @() +if ($buildApk) { + Write-Section 'Verify APK signature' + $sig = Invoke-Capture $apksigner @('verify', '--verbose', '--print-certs', $apkPath) 'apksigner verify' + $sig | ForEach-Object { Write-Host " $_" } + + # v2/v3 are the SHA-256 whole-file schemes; v1 alone is the old JAR + # signing Play no longer accepts on its own. + if (-not ($sig | Select-String -SimpleMatch 'Verified using v2 scheme (APK Signature Scheme v2): true')) { + throw 'APK is not signed with APK Signature Scheme v2.' + } + if (-not ($sig | Select-String -Pattern 'Signer #1 certificate SHA-256 digest')) { + throw 'apksigner reported no SHA-256 certificate digest.' + } + if ($sig | Select-String -Pattern 'CN=Android Debug') { + throw 'APK was signed with the Android debug key. Check android/key.properties.' + } + $certLines = @($sig | Select-String -Pattern 'Verified using|Signer #1 certificate (DN|SHA-256)' | + ForEach-Object { $_.Line.Trim() }) + Write-Ok 'v2 scheme, SHA-256 certificate, not the debug key' +} + +if ($buildBundle) { + Write-Section 'Verify App Bundle signature' + # An .aab is a JAR, so jarsigner is the right tool -- apksigner does not + # read bundles. `-strict` is deliberately absent: an app-signing certificate + # is self-signed by definition, which -strict counts as a severe warning and + # exits non-zero for, so with it the check could only ever fail. + $bundleSig = & $jarsigner '-verify' '-verbose:summary' $aabPath + $jarsignerExit = $LASTEXITCODE + $bundleSig | Select-Object -Last 10 | ForEach-Object { Write-Host " $_" } + if ($jarsignerExit -ne 0) { throw "jarsigner -verify failed with exit code $jarsignerExit" } + if (-not ($bundleSig | Select-String -SimpleMatch 'jar verified')) { + throw 'jarsigner did not report the bundle as verified.' + } + if ($bundleSig | Select-String -Pattern 'CN=Android Debug') { + throw 'The bundle was signed with the Android debug key.' + } + Write-Ok 'bundle verifies' +} + +Write-Section 'Manifest' +$manifest = Join-Path $repoRoot 'build\app\intermediates\packaged_manifests\release\processReleaseManifestForPackage\AndroidManifest.xml' +if (Test-Path $manifest) { + $manifestText = Get-Content -Raw -Path $manifest + foreach ($m in ([regex]'android:(minSdkVersion|targetSdkVersion|versionCode|versionName)="[^"]*"').Matches($manifestText)) { + Write-Host " $($m.Value)" + } + if ($manifestText -notmatch 'android:targetSdkVersion="36"') { + throw 'The merged manifest does not target SDK 36, which Play requires.' + } + Write-Ok 'targets Android 16 (API 36)' +} else { + Write-Warn "merged manifest not found at $manifest; skipped the targetSdk check" +} + +# --------------------------------------------------------------------- dist -- +Write-Section 'Artifacts' +$distDir = Join-Path $repoRoot 'dist' +if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } +$stamp = "$VersionName+$BuildNumber" +$copied = @() +if ($buildBundle) { $copied += @{ From = $aabPath; To = Join-Path $distDir "srbguide-$stamp.aab" } } +if ($buildApk) { $copied += @{ From = $apkPath; To = Join-Path $distDir "srbguide-$stamp.apk" } } +foreach ($item in $copied) { + Copy-Item -Path $item.From -Destination $item.To -Force + $hash = (Get-FileHash -Path $item.To -Algorithm SHA256).Hash + "$hash $(Split-Path -Leaf $item.To)" | Set-Content -Path "$($item.To).sha256" -Encoding ascii + $size = '{0:N1} MB' -f ((Get-Item $item.To).Length / 1MB) + Write-Detail (Split-Path -Leaf $item.To) $size +} + +Write-Section 'Done' +Write-Detail 'elapsed' ('{0:mm\:ss}' -f ([TimeSpan]((Get-Date) - $started))) +Write-Detail 'output' $distDir +if ($certLines.Count -gt 0) { + Write-Host '' + $certLines | ForEach-Object { Write-Host " $_" } +} +if ($keyFingerprintValue) { + # The Play Console shows the registered upload key in this colon-separated + # form, so print it the same way rather than making you re-derive it. + Write-Host '' + Write-Host ' Upload key SHA-256 — must match "Upload key certificate" in the Play Console:' + Write-Host " $keyFingerprintValue" +} +Write-Host @" + + Upload dist\srbguide-$stamp.aab to Play Console -> Production -> Create new release. + The APK is for sideloading and manual QA; Play takes the bundle. + + What's new, ready to paste: + docs/play/whats-new-ru-RU.txt + docs/play/whats-new-en-US.txt + + After Play accepts the upload: + - raise -PublishedBuildNumber's default in tool/build_release.ps1 to $BuildNumber + - git tag v$VersionName; git push origin v$VersionName +"@ +if ($SkipChecks) { Write-Warn 'built with -SkipChecks: analyze/tests/guide validation did not run' } + +} catch { + # These are all "you got the setup wrong" errors; a PowerShell stack trace + # buries the sentence that says which one. + $failure = $_ +} finally { + Pop-Location +} + +if ($failure) { + Write-Host '' + Write-Host "BUILD FAILED" -ForegroundColor Red + Write-Host $failure.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} diff --git a/tool/create_upload_key.ps1 b/tool/create_upload_key.ps1 new file mode 100644 index 0000000..a29b88a --- /dev/null +++ b/tool/create_upload_key.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Creates the Google Play upload key and wires it into android/key.properties. + +.DESCRIPTION + Run this ONCE. The keystore it produces is the only thing that lets you ship + an update to the existing Play listing -- back it up somewhere offline before + you upload anything. If it is lost, only Play support can reset the upload + key. + + Play requires RSA 2048 or larger and a certificate that stays valid past + 2033. `-sigalg SHA256withRSA` is what makes the certificate itself SHA-256; + tool/build_release.ps1 and the Release workflow both refuse to ship a key + that is not. + +.EXAMPLE + .\tool\create_upload_key.ps1 + +.EXAMPLE + .\tool\create_upload_key.ps1 -Keystore D:\keys\srbguide-upload.jks -Alias upload +#> +[CmdletBinding()] +param( + # Where the .jks goes. Keep it OUT of the repository. + [string]$Keystore = (Join-Path $env:USERPROFILE 'keys\srbguide-upload.jks'), + [string]$Alias = 'upload', + [string]$Dname = 'CN=Ilia Alakov, O=Serbia Guide, C=RS', + # ~27 years. Play rejects certificates that expire before 2033. + [int]$ValidityDays = 10000, + # Write the base64 of the keystore next to it, for the GitHub secret. + [switch]$PrintBase64 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Every failure here is "you got the setup wrong"; a PowerShell stack trace +# buries the sentence that says which. +trap { + Write-Host '' + Write-Host 'FAILED' -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +Write-Section 'Toolchain' +$jdk = Resolve-JdkHome +$keytool = Join-Path $jdk 'bin\keytool.exe' +if (-not (Test-Path $keytool)) { throw "keytool not found at $keytool" } +Write-Detail 'keytool' $keytool + +Write-Section 'Keystore' +if (Test-Path $Keystore) { + # Overwriting is unrecoverable: the old certificate is the only one Play + # accepts for this listing. + throw "$Keystore already exists. Refusing to touch it -- pass -Keystore for a different path, or reuse this one via android/key.properties." +} +$keystoreDir = Split-Path -Parent $Keystore +if ($keystoreDir -and -not (Test-Path $keystoreDir)) { + New-Item -ItemType Directory -Path $keystoreDir | Out-Null +} +Write-Detail 'path' $Keystore +Write-Detail 'alias' $Alias +Write-Detail 'subject' $Dname + +$secure1 = Read-Host -Prompt 'Keystore password (min 6 characters)' -AsSecureString +$secure2 = Read-Host -Prompt 'Repeat the password' -AsSecureString +$pass1 = ConvertFrom-SecureStringPlain $secure1 +$pass2 = ConvertFrom-SecureStringPlain $secure2 +if ($pass1 -ne $pass2) { throw 'The two passwords do not match.' } +if ($pass1.Length -lt 6) { throw 'keytool requires at least 6 characters.' } + +# One password for both the store and the key: keytool -genkeypair with +# -storetype PKCS12 does not support a separate key password anyway. +Invoke-Checked $keytool @( + '-genkeypair', + '-alias', $Alias, + '-keyalg', 'RSA', + '-keysize', '4096', + '-sigalg', 'SHA256withRSA', + '-validity', "$ValidityDays", + '-keystore', $Keystore, + '-storetype', 'PKCS12', + '-dname', $Dname, + '-storepass', $pass1, + '-keypass', $pass1 +) 'keytool -genkeypair' + +Write-Section 'Certificate' +$info = & $keytool -list -v -keystore $Keystore -alias $Alias -storepass $pass1 +if ($LASTEXITCODE -ne 0) { throw 'keytool -list failed' } +$info | Select-String -Pattern 'Signature algorithm name:|Valid from:|^\s+SHA256:' | ForEach-Object { Write-Host " $($_.Line.Trim())" } +if (-not ($info | Select-String -Pattern 'Signature algorithm name:\s*SHA256with')) { + throw 'The generated certificate is not SHA-256. Play will reject it.' +} + +Write-Section 'android/key.properties' +$propsPath = Join-Path $repoRoot 'android\key.properties' +if (Test-Path $propsPath) { + throw "$propsPath already exists; not overwriting. Point it at the new keystore by hand if that is what you want." +} +# Gradle reads this file as a java.util.Properties, where `\` starts an escape +# and `:` can separate a key from its value. The replacement string is taken +# literally, so '\\' here means the two characters Properties decodes back into +# one backslash. +$escapedStore = $Keystore -replace '\\', '\\' -replace ':', '\:' +$propsText = @( + "storeFile=$escapedStore", + "storePassword=$pass1", + "keyAlias=$Alias", + "keyPassword=$pass1" +) -join "`n" +# Properties.load(InputStream) decodes ISO-8859-1, so a UTF-8 BOM would be read +# as three characters glued to the first key and `storeFile` would come back +# null. Write plain ASCII, escaping anything above it the way Properties expects. +$ascii = -join ($propsText.ToCharArray() | ForEach-Object { + if ([int]$_ -lt 128) { $_ } else { '\u{0:x4}' -f [int]$_ } +}) +[IO.File]::WriteAllText($propsPath, $ascii + "`n", (New-Object Text.UTF8Encoding $false)) +Write-Detail 'written' $propsPath +Write-Host ' (git-ignored; it holds the passwords in clear text)' + +if ($PrintBase64) { + Write-Section 'ANDROID_KEYSTORE_BASE64' + $b64Path = "$Keystore.base64.txt" + [Convert]::ToBase64String([IO.File]::ReadAllBytes($Keystore)) | Set-Content -Path $b64Path -Encoding ascii -NoNewline + Write-Detail 'written' $b64Path + Write-Host ' Paste its contents into the GitHub secret, then delete the file.' +} + +# Play asks for the key one of two ways depending on the screen: a SHA-256 +# fingerprint to paste, or a PEM certificate to upload. Produce both. +Write-Section 'For the Play Console' +& (Join-Path $PSScriptRoot 'show_upload_key.ps1') -Keystore $Keystore -Alias $Alias -StorePassword $pass1 -NoHeader + +Write-Section 'Next' +Write-Host @" + 1. Back up $Keystore and its password offline. Losing them means you can + never update the existing Play listing. + 2. Register the key with Play (fingerprint or PEM, printed above). + 3. Build a release: .\tool\build_release.ps1 + 4. For CI, add the four repository secrets listed in docs/RELEASE.md + (re-run this script with -PrintBase64 to get ANDROID_KEYSTORE_BASE64). +"@ diff --git a/tool/release_common.ps1 b/tool/release_common.ps1 new file mode 100644 index 0000000..754babc --- /dev/null +++ b/tool/release_common.ps1 @@ -0,0 +1,181 @@ +<# + Shared helpers for tool/build_release.ps1 and tool/create_upload_key.ps1. + Dot-source it; it defines functions only. + + Targets Windows PowerShell 5.1, so: no `&&`/`||`, no ternary, no `??`. +#> + +function Write-Section { + param([Parameter(Mandatory)][string]$Title) + Write-Host '' + Write-Host "== $Title" -ForegroundColor Cyan +} + +function Write-Detail { + param([string]$Label, [string]$Value) + Write-Host (" {0,-14} {1}" -f $Label, $Value) +} + +function Write-Ok { + param([string]$Message) + Write-Host " OK $Message" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Message) + Write-Host " !! $Message" -ForegroundColor Yellow +} + +<# + Runs a native executable and throws on a non-zero exit code. + + stderr is deliberately left unredirected: `2>&1` on a native command in + PowerShell 5.1 wraps each stderr line in an ErrorRecord and reports failure + even when the exit code was 0. +#> +function Invoke-Checked { + param( + [Parameter(Mandatory)][string]$Exe, + [string[]]$Arguments = @(), + [string]$What + ) + if (-not $What) { $What = Split-Path -Leaf $Exe } + & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$What failed with exit code $LASTEXITCODE" + } +} + +# Same, but returns stdout as a string array instead of streaming it. +function Invoke-Capture { + param( + [Parameter(Mandatory)][string]$Exe, + [string[]]$Arguments = @(), + [string]$What + ) + if (-not $What) { $What = Split-Path -Leaf $Exe } + $output = & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { + $output | ForEach-Object { Write-Host $_ } + throw "$What failed with exit code $LASTEXITCODE" + } + return @($output) +} + +function ConvertFrom-SecureStringPlain { + param([Parameter(Mandatory)][System.Security.SecureString]$Secure) + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) + } finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } +} + +# Reads a java.util.Properties-style file into a hashtable. Enough for +# local.properties and key.properties: `key=value`, `#`/`!` comments, +# backslash escapes in the value. +function Read-JavaProperties { + param([Parameter(Mandatory)][string]$Path) + $map = @{} + foreach ($line in (Get-Content -Path $Path -Encoding utf8)) { + $trimmed = $line.Trim() + if (-not $trimmed) { continue } + if ($trimmed.StartsWith('#') -or $trimmed.StartsWith('!')) { continue } + $sep = $trimmed.IndexOf('=') + if ($sep -lt 1) { continue } + $key = $trimmed.Substring(0, $sep).Trim() + $value = $trimmed.Substring($sep + 1).Trim() + # Undo the escaping Gradle's Properties loader would apply. + $value = $value -replace '\\:', ':' -replace '\\\\', '\' + $map[$key] = $value + } + return $map +} + +<# + Finds the JDK that owns keytool/jarsigner, preferring the one Flutter is + pinned to. Android Studio's bundled JBR is deliberately not used -- see + SETUP.md. +#> +function Resolve-JdkHome { + $candidates = New-Object System.Collections.Generic.List[string] + + $settingsPath = Join-Path $env:USERPROFILE '.flutter_settings' + if (Test-Path $settingsPath) { + try { + $settings = Get-Content -Raw -Path $settingsPath | ConvertFrom-Json + if ($settings.PSObject.Properties.Name -contains 'jdk-dir') { + $candidates.Add($settings.'jdk-dir') + } + } catch { + # A malformed settings file is not worth failing the build over. + } + } + if ($env:JAVA_HOME) { $candidates.Add($env:JAVA_HOME) } + $candidates.Add('D:\dev\jdk21') + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path (Join-Path $candidate 'bin\keytool.exe'))) { + return $candidate + } + } + $onPath = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($onPath) { return (Split-Path -Parent (Split-Path -Parent $onPath.Source)) } + + throw 'No JDK found. Set JAVA_HOME, or run `flutter config --jdk-dir ` (SETUP.md pins Temurin 21).' +} + +function Resolve-FlutterExe { + param([string]$RepoRoot) + + $localProps = Join-Path $RepoRoot 'android\local.properties' + if (Test-Path $localProps) { + $props = Read-JavaProperties $localProps + if ($props.ContainsKey('flutter.sdk')) { + $fromProps = Join-Path ($props['flutter.sdk'] -replace '/', '\') 'bin\flutter.bat' + if (Test-Path $fromProps) { return $fromProps } + } + } + $onPath = Get-Command flutter.bat -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + if (Test-Path 'D:\dev\flutter\bin\flutter.bat') { return 'D:\dev\flutter\bin\flutter.bat' } + + throw 'flutter.bat not found. Put the Flutter SDK on PATH or set flutter.sdk in android/local.properties.' +} + +function Resolve-AndroidSdk { + param([string]$RepoRoot) + + $candidates = New-Object System.Collections.Generic.List[string] + $localProps = Join-Path $RepoRoot 'android\local.properties' + if (Test-Path $localProps) { + $props = Read-JavaProperties $localProps + if ($props.ContainsKey('sdk.dir')) { $candidates.Add(($props['sdk.dir'] -replace '/', '\')) } + } + if ($env:ANDROID_SDK_ROOT) { $candidates.Add($env:ANDROID_SDK_ROOT) } + if ($env:ANDROID_HOME) { $candidates.Add($env:ANDROID_HOME) } + $candidates.Add((Join-Path $env:LOCALAPPDATA 'Android\Sdk')) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path (Join-Path $candidate 'build-tools'))) { return $candidate } + } + throw 'Android SDK not found. Set ANDROID_SDK_ROOT or sdk.dir in android/local.properties.' +} + +# Newest build-tools directory, by version rather than by name -- 36.0.0 has to +# sort above 9.0.0. +function Resolve-ApkSigner { + param([Parameter(Mandatory)][string]$AndroidSdk) + + $buildTools = @(Get-ChildItem -Path (Join-Path $AndroidSdk 'build-tools') -Directory | + Sort-Object -Property @{ Expression = { + $parsed = $null + if ([Version]::TryParse($_.Name, [ref]$parsed)) { $parsed } else { [Version]'0.0.0' } + } }) + for ($i = $buildTools.Count - 1; $i -ge 0; $i--) { + $candidate = Join-Path $buildTools[$i].FullName 'apksigner.bat' + if (Test-Path $candidate) { return $candidate } + } + throw "No apksigner.bat under $AndroidSdk\build-tools. Install build-tools with sdkmanager." +} diff --git a/tool/show_upload_key.ps1 b/tool/show_upload_key.ps1 new file mode 100644 index 0000000..7a0ab14 --- /dev/null +++ b/tool/show_upload_key.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS + Prints what the Play Console asks for when you register an upload key: the + SHA-256 certificate fingerprint, and a PEM export of the certificate. + +.DESCRIPTION + Play has two ways of taking an upload key, and which one you get depends on + the screen you are on: + + "Добавьте открытый ключ ... цифровой отпечаток сертификата SHA-256" + -> paste the SHA-256 line this prints. + + Upload key reset / "Upload a certificate" + -> upload the .pem file this writes. + + Neither is a secret: a fingerprint is a hash of the certificate and the PEM + holds only the public half. The keystore and its password are the secret, + and this script never copies either. + + Reads android/key.properties by default, so after create_upload_key.ps1 it + needs no arguments. + +.EXAMPLE + .\tool\show_upload_key.ps1 + +.EXAMPLE + .\tool\show_upload_key.ps1 -Keystore D:\keys\upload.jks -Alias upload +#> +[CmdletBinding()] +param( + # Defaults to storeFile from android/key.properties. + [string]$Keystore, + # Defaults to keyAlias from android/key.properties. + [string]$Alias, + # Defaults to storePassword from android/key.properties; prompts if neither. + [string]$StorePassword, + # Where to write the PEM. Defaults to next to the keystore. + [string]$PemPath, + # Suppress the banner when called from create_upload_key.ps1. + [switch]$NoHeader +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +trap { + Write-Host '' + Write-Host 'FAILED' -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +if (-not $Keystore -or -not $Alias -or -not $StorePassword) { + $propsPath = Join-Path $repoRoot 'android\key.properties' + if (-not (Test-Path $propsPath)) { + throw "android/key.properties not found, and -Keystore / -Alias / -StorePassword were not all given. Create a key first: .\tool\create_upload_key.ps1" + } + $props = Read-JavaProperties $propsPath + if (-not $Keystore) { $Keystore = $props['storeFile'] } + if (-not $Alias) { $Alias = $props['keyAlias'] } + if (-not $StorePassword) { $StorePassword = $props['storePassword'] } +} +if (-not (Test-Path $Keystore)) { throw "Keystore not found: $Keystore" } + +$jdk = Resolve-JdkHome +$keytool = Join-Path $jdk 'bin\keytool.exe' +if (-not (Test-Path $keytool)) { throw "keytool not found at $keytool" } + +if (-not $NoHeader) { + Write-Section 'Upload key' + Write-Detail 'keystore' $Keystore + Write-Detail 'alias' $Alias +} + +$info = Invoke-Capture $keytool @( + '-list', '-v', + '-keystore', $Keystore, + '-alias', $Alias, + '-storepass', $StorePassword +) 'keytool -list' + +foreach ($pattern in @('^Owner:', 'Signature algorithm name:', 'Valid from:')) { + $line = $info | Select-String -Pattern $pattern | Select-Object -First 1 + if ($line) { Write-Host " $($line.Line.Trim())" } +} + +$sha256 = $info | Select-String -Pattern '^\s+SHA256:\s*(\S+)' | Select-Object -First 1 +if (-not $sha256) { throw 'keytool did not report a SHA-256 fingerprint.' } +$fingerprint = $sha256.Matches[0].Groups[1].Value + +Write-Host '' +Write-Host ' SHA-256 fingerprint (paste this into the Play Console):' +Write-Host '' +Write-Host " $fingerprint" -ForegroundColor Cyan +Write-Host '' + +if (-not $PemPath) { $PemPath = [IO.Path]::ChangeExtension($Keystore, 'pem') } +# -rfc is the base64 PEM form; without it keytool writes DER, which the upload +# form rejects. +Invoke-Checked $keytool @( + '-exportcert', '-rfc', + '-keystore', $Keystore, + '-alias', $Alias, + '-storepass', $StorePassword, + '-file', $PemPath +) 'keytool -exportcert' +Write-Detail 'certificate' $PemPath +Write-Host ' Upload that file on screens that ask for a certificate rather than' +Write-Host ' a fingerprint. It is the public half only, safe to send to Google.' From 3cc2f4d7151e8ef54648bad370a16a716d7ad69e Mon Sep 17 00:00:00 2001 From: ialakey Date: Sun, 6 Sep 2026 16:13:36 +0200 Subject: [PATCH 2/4] feat: fold the maps screen into the places map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Карты" and "Места" were two answers to the same question. The former was a dropdown of four Google My Maps links opened in a WebView; the latter is the OpenStreetMap catalogue of relocant-run businesses. Everything the old screen pointed at now lives on the new map: - Russian venues needed no work: that map is the stats.srb.guide catalogue the places screen already shows, and the bundled copy is fresher (362 vs 314 points). - Non-smoking venues are bundled as pins. tool/sync_smoking.dart reads the KML export of the "Lokali bez dima" map into assets/data/smoking.json — 145 venues, 94 where smoking is banned outright and 51 where only smokeless devices are allowed — and the weekly sync workflow keeps it current. - Exchange offices were never a dataset, only a live Google Maps search for "Мењачница"; it is now a button in the map's toolbar. - The "black list of apartments" link is dropped. Its My Map has been answering Google 403 for a while, so it was already dead in the shipped app. Filter chips cover the smoking policy next to the business categories, and a venue on both lists is matched by name and proximity so it gets one pin rather than two. Deleting the screen also drops webview_flutter, which nothing else used. --- .github/workflows/sync-content.yml | 19 +- assets/data/locations.json | 26 - assets/data/smoking.json | 1747 ++++++++++++++++++++++++++++ lib/data/place.dart | 98 ++ lib/l10n/app_en.arb | 11 +- lib/l10n/app_ru.arb | 11 +- lib/screens/map.dart | 156 --- lib/screens/places.dart | 121 +- lib/screens/services.dart | 10 +- pubspec.lock | 32 - pubspec.yaml | 1 - test/place_merge_test.dart | 127 ++ tool/sync_smoking.dart | 190 +++ 13 files changed, 2302 insertions(+), 247 deletions(-) delete mode 100644 assets/data/locations.json create mode 100644 assets/data/smoking.json delete mode 100644 lib/screens/map.dart create mode 100644 test/place_merge_test.dart create mode 100644 tool/sync_smoking.dart diff --git a/.github/workflows/sync-content.yml b/.github/workflows/sync-content.yml index b9c4c7d..a3c7e1b 100644 --- a/.github/workflows/sync-content.yml +++ b/.github/workflows/sync-content.yml @@ -1,10 +1,11 @@ name: Sync content -# Re-scrapes the three bundled datasets once a week so the offline copies do not +# Re-scrapes the four bundled datasets once a week so the offline copies do not # drift from their sources: # # assets/data/guide.json <- srb.guide (74 articles) # assets/data/places.json <- stats.srb.guide/map (relocant businesses) +# assets/data/smoking.json <- Google My Maps KML (non-smoking venues) # assets/data/tg_chats.json <- stats.srb.guide (chat directory) # # Everything is validated before anything is committed — see @@ -30,6 +31,7 @@ env: FLUTTER_VERSION: "3.47.2" GUIDE: assets/data/guide.json PLACES: assets/data/places.json + SMOKING: assets/data/smoking.json CHATS: assets/data/tg_chats.json jobs: @@ -52,6 +54,7 @@ jobs: run: | cp "$GUIDE" /tmp/guide-baseline.json cp "$PLACES" /tmp/places-baseline.json + cp "$SMOKING" /tmp/smoking-baseline.json cp "$CHATS" /tmp/chats-baseline.json - name: Scrape srb.guide @@ -63,6 +66,9 @@ jobs: - name: Scrape the places catalogue run: dart run tool/sync_places.dart + - name: Scrape the non-smoking map + run: dart run tool/sync_smoking.dart + - name: Scrape the chat directory run: dart run tool/sync_chats.dart @@ -81,6 +87,10 @@ jobs: places_after=$(jq -S -c '.places' "$PLACES" | sha256sum | cut -d' ' -f1) [ "$places_before" != "$places_after" ] && changed="$changed $PLACES" + smoking_before=$(jq -S -c '.places' /tmp/smoking-baseline.json | sha256sum | cut -d' ' -f1) + smoking_after=$(jq -S -c '.places' "$SMOKING" | sha256sum | cut -d' ' -f1) + [ "$smoking_before" != "$smoking_after" ] && changed="$changed $SMOKING" + chats_before=$(jq -S -c '.' /tmp/chats-baseline.json | sha256sum | cut -d' ' -f1) chats_after=$(jq -S -c '.' "$CHATS" | sha256sum | cut -d' ' -f1) [ "$chats_before" != "$chats_after" ] && changed="$changed $CHATS" @@ -98,6 +108,7 @@ jobs: { echo "articles=$(jq '[.ru[].items[]] | length' "$GUIDE")" echo "places=$(jq '.places | length' "$PLACES")" + echo "smoking=$(jq '.places | length' "$SMOKING")" echo "chats=$(jq 'length' "$CHATS")" } >> "$GITHUB_OUTPUT" @@ -111,6 +122,7 @@ jobs: echo "|---|---|" echo "| guide.json | ${{ steps.diff.outputs.articles }} articles |" echo "| places.json | ${{ steps.diff.outputs.places }} places |" + echo "| smoking.json | ${{ steps.diff.outputs.smoking }} venues |" echo "| tg_chats.json | ${{ steps.diff.outputs.chats }} chats |" echo "" echo "Changed: \`${{ steps.diff.outputs.files }}\`" @@ -118,7 +130,7 @@ jobs: - name: Discard a no-op scrape if: steps.diff.outputs.changed != 'true' - run: git checkout -- "$GUIDE" "$PLACES" "$CHATS" + run: git checkout -- "$GUIDE" "$PLACES" "$SMOKING" "$CHATS" - name: Commit to master if: steps.diff.outputs.changed == 'true' && inputs.open_pull_request != true @@ -133,6 +145,7 @@ jobs: Automated weekly scrape. guide ${{ steps.diff.outputs.articles }} articles · places ${{ steps.diff.outputs.places }} · + smoking ${{ steps.diff.outputs.smoking }} · chats ${{ steps.diff.outputs.chats }}." git push @@ -150,6 +163,7 @@ jobs: |---|---| | `guide.json` | ${{ steps.diff.outputs.articles }} articles | | `places.json` | ${{ steps.diff.outputs.places }} places | + | `smoking.json` | ${{ steps.diff.outputs.smoking }} venues | | `tg_chats.json` | ${{ steps.diff.outputs.chats }} chats | The guide was validated by `tool/validate_guide.dart` against the @@ -157,4 +171,5 @@ jobs: add-paths: | ${{ env.GUIDE }} ${{ env.PLACES }} + ${{ env.SMOKING }} ${{ env.CHATS }} diff --git a/assets/data/locations.json b/assets/data/locations.json deleted file mode 100644 index d2d0b9d..0000000 --- a/assets/data/locations.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "iconPath": "usd.png", - "url": "https://www.google.com/maps/search/Мењачница", - "title": "Обменники", - "section": "Карты" - }, - { - "iconPath": "angry.png", - "url": "https://t.ly/YAx6", - "title": "Черный список квартир", - "section": "Карты" - }, - { - "iconPath": "no-smoking.png", - "url": "https://www.google.com/maps/d/viewer?mid=1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo&usp=sharing", - "title": "Не курящие", - "section": "Карты" - }, - { - "iconPath": "tea.png", - "url": "https://www.google.com/maps/d/u/0/viewer?mid=12l4BVYg_FV0d9CMeEWEtnJDQioL9804&ll=sharing", - "title": "Русские заведения", - "section": "Карты" - } -] diff --git a/assets/data/smoking.json b/assets/data/smoking.json new file mode 100644 index 0000000..2974fc0 --- /dev/null +++ b/assets/data/smoking.json @@ -0,0 +1,1747 @@ +{ + "source": "https://www.google.com/maps/d/viewer?mid=1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo", + "upstream": "https://lokalibezdima.rs", + "syncedAt": "2026-09-06T13:43:04.675541Z", + "places": [ + { + "id": "smoke-ba805bd5", + "name": "Ananda", + "description": "", + "lat": 45.2493376, + "lng": 19.8395276, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2493376,19.8395276", + "smoking": "none" + }, + { + "id": "smoke-64e87574", + "name": "April bar", + "description": "", + "lat": 44.8168485, + "lng": 20.4657284, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8168485,20.4657284", + "smoking": "none" + }, + { + "id": "smoke-294acc15", + "name": "ArkaBarka floating hostel & apartments", + "description": "", + "lat": 44.822369, + "lng": 20.4310824, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.822369,20.4310824", + "smoking": "alternative" + }, + { + "id": "smoke-0d023bdc", + "name": "Artist Specialty Coffee", + "description": "", + "lat": 44.8149429, + "lng": 20.4654855, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8149429,20.4654855", + "smoking": "none" + }, + { + "id": "smoke-c4c8fbeb", + "name": "BIG Novi Sad", + "description": "", + "lat": 45.276412, + "lng": 19.826218, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.276412,19.826218", + "smoking": "none" + }, + { + "id": "smoke-1b3f15cb", + "name": "Bad Sushi", + "description": "", + "lat": 44.8159897, + "lng": 20.466228, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8159897,20.466228", + "smoking": "none" + }, + { + "id": "smoke-56ded63a", + "name": "Block 32", + "description": "", + "lat": 45.2603789, + "lng": 19.8302549, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2603789,19.8302549", + "smoking": "none" + }, + { + "id": "smoke-2a25ffa2", + "name": "Bloom", + "description": "", + "lat": 44.8208969, + "lng": 20.4593476, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8208969,20.4593476", + "smoking": "none" + }, + { + "id": "smoke-2ae06735", + "name": "Bon Vivant", + "description": "", + "lat": 44.820286, + "lng": 20.4650778, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.820286,20.4650778", + "smoking": "none" + }, + { + "id": "smoke-fad1ee05", + "name": "Bookastore", + "description": "", + "lat": 44.8179982, + "lng": 20.4544675, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8179982,20.4544675", + "smoking": "none" + }, + { + "id": "smoke-fa77e0bd", + "name": "Branč", + "description": "", + "lat": 44.8216604, + "lng": 20.4563429, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8216604,20.4563429", + "smoking": "none" + }, + { + "id": "smoke-5e8517cf", + "name": "Bruno bar", + "description": "", + "lat": 45.2422504, + "lng": 19.8252851, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2422504,19.8252851", + "smoking": "none" + }, + { + "id": "smoke-34645bd7", + "name": "Bulevar Books", + "description": "", + "lat": 45.2531798, + "lng": 19.8453733, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2531798,19.8453733", + "smoking": "alternative" + }, + { + "id": "smoke-7c851656", + "name": "Burrito Madre", + "description": "", + "lat": 44.8131067, + "lng": 20.461389, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8131067,20.461389", + "smoking": "none" + }, + { + "id": "smoke-6070297f", + "name": "Cafe \"Zona Industriale\"", + "description": "", + "lat": 44.8025388, + "lng": 20.470947, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8025388,20.470947", + "smoking": "none" + }, + { + "id": "smoke-2f5c01c1", + "name": "Cafe & Factory 11", + "description": "", + "lat": 44.7813329, + "lng": 20.4169093, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7813329,20.4169093", + "smoking": "alternative" + }, + { + "id": "smoke-e6d9f7cc", + "name": "Caffe SANTANA", + "description": "", + "lat": 43.8792079, + "lng": 20.3572178, + "category": "", + "city": "cacak", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.8792079,20.3572178", + "smoking": "alternative" + }, + { + "id": "smoke-b1010ab0", + "name": "Cecina CAFFE poslastičarnica", + "description": "", + "lat": 46.1019171, + "lng": 19.6622355, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.1019171,19.6622355", + "smoking": "none" + }, + { + "id": "smoke-03993dd4", + "name": "City Poslastičarnica", + "description": "", + "lat": 45.2545942, + "lng": 19.8434065, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2545942,19.8434065", + "smoking": "none" + }, + { + "id": "smoke-d2062848", + "name": "Coffee Shop", + "description": "", + "lat": 44.7986352, + "lng": 20.4875252, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7986352,20.4875252", + "smoking": "none" + }, + { + "id": "smoke-09b104a2", + "name": "Coffeedream", + "description": "", + "lat": 44.8188686, + "lng": 20.4549917, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8188686,20.4549917", + "smoking": "none" + }, + { + "id": "smoke-70bad525", + "name": "Coffeedream", + "description": "", + "lat": 45.2491428, + "lng": 19.8392051, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2491428,19.8392051", + "smoking": "alternative" + }, + { + "id": "smoke-7a20179f", + "name": "Crna Ovca", + "description": "", + "lat": 44.8214195, + "lng": 20.4572955, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8214195,20.4572955", + "smoking": "none" + }, + { + "id": "smoke-144b1996", + "name": "Crni Ovan", + "description": "", + "lat": 45.2560476, + "lng": 19.8482954, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2560476,19.8482954", + "smoking": "none" + }, + { + "id": "smoke-15595aa4", + "name": "Crowne Plaza Belgrade", + "description": "", + "lat": 44.809397, + "lng": 20.4342642, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.809397,20.4342642", + "smoking": "alternative" + }, + { + "id": "smoke-11184d56", + "name": "Cruise Cocktail Bar", + "description": "", + "lat": 44.7959796, + "lng": 20.3995614, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7959796,20.3995614", + "smoking": "alternative" + }, + { + "id": "smoke-1fd47474", + "name": "Curry Souls", + "description": "", + "lat": 44.8162377, + "lng": 20.4568724, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8162377,20.4568724", + "smoking": "alternative" + }, + { + "id": "smoke-84c800c0", + "name": "D'oliva bar", + "description": "", + "lat": 44.8212249, + "lng": 20.46581, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8212249,20.46581", + "smoking": "alternative" + }, + { + "id": "smoke-5841be1d", + "name": "D59B | Belgrade", + "description": "", + "lat": 44.8219191, + "lng": 20.4582529, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8219191,20.4582529", + "smoking": "none" + }, + { + "id": "smoke-a018dbc4", + "name": "DRIP Specialty Coffee", + "description": "", + "lat": 44.8192136, + "lng": 20.461999, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8192136,20.461999", + "smoking": "none" + }, + { + "id": "smoke-d7d4c276", + "name": "DVA MEDVEDA", + "description": "", + "lat": 44.8152671, + "lng": 20.456029, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8152671,20.456029", + "smoking": "none" + }, + { + "id": "smoke-af486641", + "name": "Delicent", + "description": "", + "lat": 44.823535, + "lng": 20.4622363, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.823535,20.4622363", + "smoking": "none" + }, + { + "id": "smoke-186bef7c", + "name": "Demokratija cocktails&coffee bar", + "description": "", + "lat": 44.8171251, + "lng": 20.4651504, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171251,20.4651504", + "smoking": "none" + }, + { + "id": "smoke-38f82c8d", + "name": "Diavolino", + "description": "", + "lat": 44.7986381, + "lng": 20.4779352, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7986381,20.4779352", + "smoking": "alternative" + }, + { + "id": "smoke-8c7a1f5a", + "name": "Dom kulture Studentski grad", + "description": "", + "lat": 44.8248772, + "lng": 20.4006511, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8248772,20.4006511", + "smoking": "none" + }, + { + "id": "smoke-c42dd245", + "name": "ENDORFIN", + "description": "", + "lat": 44.8186623, + "lng": 20.4601451, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8186623,20.4601451", + "smoking": "none" + }, + { + "id": "smoke-f3ceb6b4", + "name": "Eatalian Food Bar Dorćol", + "description": "", + "lat": 44.8193513, + "lng": 20.4622299, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8193513,20.4622299", + "smoking": "alternative" + }, + { + "id": "smoke-9285ba6c", + "name": "FENIKS Gelato Saloon", + "description": "", + "lat": 44.8154382, + "lng": 20.4587198, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8154382,20.4587198", + "smoking": "none" + }, + { + "id": "smoke-7cb007eb", + "name": "FREESHKA COFFEE Co.", + "description": "", + "lat": 45.2469744, + "lng": 19.839275, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2469744,19.839275", + "smoking": "none" + }, + { + "id": "smoke-1d93ef7d", + "name": "Fameli coffee bar and play", + "description": "", + "lat": 44.797843, + "lng": 20.4761563, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.797843,20.4761563", + "smoking": "alternative" + }, + { + "id": "smoke-f68fa8e4", + "name": "Family Cup", + "description": "", + "lat": 44.8128023, + "lng": 20.4293876, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8128023,20.4293876", + "smoking": "none" + }, + { + "id": "smoke-cb6821ce", + "name": "Fat Boys Food co.", + "description": "", + "lat": 44.8246333, + "lng": 20.4560706, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8246333,20.4560706", + "smoking": "alternative" + }, + { + "id": "smoke-43788704", + "name": "Ferdinand Dumplings", + "description": "", + "lat": 44.8126059, + "lng": 20.4297963, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126059,20.4297963", + "smoking": "none" + }, + { + "id": "smoke-e9895c09", + "name": "Ferdinand knedle", + "description": "", + "lat": 44.8170045, + "lng": 20.4553833, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8170045,20.4553833", + "smoking": "alternative" + }, + { + "id": "smoke-d5dcc133", + "name": "Filipenko Coffee Bar", + "description": "", + "lat": 45.2568858, + "lng": 19.8434286, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2568858,19.8434286", + "smoking": "none" + }, + { + "id": "smoke-ab9528f4", + "name": "Fine Sushi", + "description": "", + "lat": 44.8033771, + "lng": 20.4778752, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8033771,20.4778752", + "smoking": "none" + }, + { + "id": "smoke-ac48f8f3", + "name": "Fini", + "description": "", + "lat": 44.7985102, + "lng": 20.4715454, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7985102,20.4715454", + "smoking": "none" + }, + { + "id": "smoke-de7c856d", + "name": "Fit Bar", + "description": "", + "lat": 44.8137855, + "lng": 20.4615573, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8137855,20.4615573", + "smoking": "none" + }, + { + "id": "smoke-ffcd50e6", + "name": "Forum", + "description": "", + "lat": 44.820605, + "lng": 20.4692783, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.820605,20.4692783", + "smoking": "none" + }, + { + "id": "smoke-dbb899ce", + "name": "GIR Café", + "description": "", + "lat": 44.8251472, + "lng": 20.4176994, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8251472,20.4176994", + "smoking": "none" + }, + { + "id": "smoke-641c686d", + "name": "Gabby Caffe & Bar", + "description": "", + "lat": 45.2566629, + "lng": 19.8484749, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2566629,19.8484749", + "smoking": "none" + }, + { + "id": "smoke-d0fd34ed", + "name": "Galito's", + "description": "", + "lat": 44.803779, + "lng": 20.4452948, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.803779,20.4452948", + "smoking": "none" + }, + { + "id": "smoke-2df642ca", + "name": "Giros Land", + "description": "", + "lat": 45.2569187, + "lng": 19.8447314, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2569187,19.8447314", + "smoking": "alternative" + }, + { + "id": "smoke-b9333fe9", + "name": "Go Sushi", + "description": "", + "lat": 44.8059909, + "lng": 20.4665366, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8059909,20.4665366", + "smoking": "none" + }, + { + "id": "smoke-71c00f49", + "name": "Gostoprimnica", + "description": "", + "lat": 43.9026007, + "lng": 22.2744054, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.9026007,22.2744054", + "smoking": "none" + }, + { + "id": "smoke-b6887494", + "name": "Greenet Stari Grad", + "description": "", + "lat": 44.8137759, + "lng": 20.461531, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8137759,20.461531", + "smoking": "alternative" + }, + { + "id": "smoke-65cd6a30", + "name": "Gurme", + "description": "", + "lat": 44.809553, + "lng": 20.4760718, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.809553,20.4760718", + "smoking": "alternative" + }, + { + "id": "smoke-9fc927b4", + "name": "Hilton Belgrade", + "description": "", + "lat": 44.8040303, + "lng": 20.4661686, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8040303,20.4661686", + "smoking": "alternative" + }, + { + "id": "smoke-7ddfdfed", + "name": "Hotel Prag", + "description": "", + "lat": 44.810841, + "lng": 20.459772, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.810841,20.459772", + "smoking": "none" + }, + { + "id": "smoke-1078b9d0", + "name": "Hyatt Regency Belgrade", + "description": "", + "lat": 44.813059, + "lng": 20.4340388, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.813059,20.4340388", + "smoking": "alternative" + }, + { + "id": "smoke-6545e947", + "name": "Ispeci pa reci", + "description": "", + "lat": 44.8204973, + "lng": 20.4690126, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8204973,20.4690126", + "smoking": "none" + }, + { + "id": "smoke-e0586f03", + "name": "Ispeci pa reci Zvezdara", + "description": "", + "lat": 44.7964361, + "lng": 20.4931559, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7964361,20.4931559", + "smoking": "none" + }, + { + "id": "smoke-b6b3e5de", + "name": "Izlet", + "description": "", + "lat": 45.2560558, + "lng": 19.8516777, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2560558,19.8516777", + "smoking": "alternative" + }, + { + "id": "smoke-7ac2c49c", + "name": "JaM", + "description": "", + "lat": 44.8189518, + "lng": 20.4716991, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8189518,20.4716991", + "smoking": "alternative" + }, + { + "id": "smoke-678da813", + "name": "Kafetea", + "description": "", + "lat": 45.2615304, + "lng": 19.8521021, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2615304,19.8521021", + "smoking": "none" + }, + { + "id": "smoke-2d039afd", + "name": "Kafeterija Košutnjak", + "description": "", + "lat": 44.7667377, + "lng": 20.4270799, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7667377,20.4270799", + "smoking": "none" + }, + { + "id": "smoke-5d908322", + "name": "Kafeterija „Bukmarker“ | Lagunin klub čitalaca", + "description": "", + "lat": 44.8190466, + "lng": 20.455038, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8190466,20.455038", + "smoking": "alternative" + }, + { + "id": "smoke-8ca2c5d0", + "name": "Kafić BeWell energy", + "description": "", + "lat": 44.7974816, + "lng": 20.492269, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7974816,20.492269", + "smoking": "alternative" + }, + { + "id": "smoke-13f3b939", + "name": "Kaži Važi", + "description": "", + "lat": 44.8095823, + "lng": 20.4579601, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8095823,20.4579601", + "smoking": "none" + }, + { + "id": "smoke-bd617a47", + "name": "Keg&Krigla", + "description": "", + "lat": 43.8869231, + "lng": 20.3506779, + "category": "", + "city": "cacak", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.8869231,20.3506779", + "smoking": "alternative" + }, + { + "id": "smoke-e8690b2d", + "name": "Klein House Social Bar and Art Gallery", + "description": "", + "lat": 46.1010974, + "lng": 19.6684838, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.1010974,19.6684838", + "smoking": "alternative" + }, + { + "id": "smoke-9af26806", + "name": "Komuna Gastro Bar", + "description": "", + "lat": 43.3186575, + "lng": 21.8941198, + "category": "", + "city": "nis", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.3186575,21.8941198", + "smoking": "alternative" + }, + { + "id": "smoke-c01b8926", + "name": "Koppa specialty coffee", + "description": "", + "lat": 44.7995499, + "lng": 20.4699495, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7995499,20.4699495", + "smoking": "none" + }, + { + "id": "smoke-4ee66959", + "name": "Kurtoš kolač NS", + "description": "", + "lat": 45.2566554, + "lng": 19.848729, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2566554,19.848729", + "smoking": "none" + }, + { + "id": "smoke-f3990fee", + "name": "Kuća Umetnica", + "description": "", + "lat": 44.8208265, + "lng": 20.4594723, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8208265,20.4594723", + "smoking": "none" + }, + { + "id": "smoke-8169adf3", + "name": "LUFF GELATO \"West 65\"", + "description": "", + "lat": 44.8132917, + "lng": 20.4009041, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8132917,20.4009041", + "smoking": "none" + }, + { + "id": "smoke-af6fbb11", + "name": "LUFF GELATO Dorćol", + "description": "", + "lat": 44.8207292, + "lng": 20.4560944, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8207292,20.4560944", + "smoking": "none" + }, + { + "id": "smoke-e3d00699", + "name": "LUFF GELATO TC \"Ada Mall\"", + "description": "", + "lat": 44.786713, + "lng": 20.4185736, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.786713,20.4185736", + "smoking": "none" + }, + { + "id": "smoke-0e0dfe10", + "name": "LUFF GELATO TC \"Galerija\"", + "description": "", + "lat": 44.8032376, + "lng": 20.4452358, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8032376,20.4452358", + "smoking": "none" + }, + { + "id": "smoke-22ae25bb", + "name": "LUFF GELATO Vračar", + "description": "", + "lat": 44.8037848, + "lng": 20.4691259, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8037848,20.4691259", + "smoking": "none" + }, + { + "id": "smoke-24845b41", + "name": "La la kafe", + "description": "", + "lat": 44.7982191, + "lng": 20.4832724, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7982191,20.4832724", + "smoking": "none" + }, + { + "id": "smoke-5f31471d", + "name": "Lava Nova", + "description": "", + "lat": 44.8016751, + "lng": 20.4561152, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8016751,20.4561152", + "smoking": "alternative" + }, + { + "id": "smoke-13149305", + "name": "Le Bol", + "description": "", + "lat": 45.2558185, + "lng": 19.8491643, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2558185,19.8491643", + "smoking": "alternative" + }, + { + "id": "smoke-97a674ce", + "name": "Ljiljan cocktail bar", + "description": "", + "lat": 46.0880927, + "lng": 19.6716677, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.0880927,19.6716677", + "smoking": "alternative" + }, + { + "id": "smoke-29a42300", + "name": "Loft Biciklana", + "description": "", + "lat": 45.2399804, + "lng": 19.8474436, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2399804,19.8474436", + "smoking": "none" + }, + { + "id": "smoke-9bb5147f", + "name": "Loft Boulevard", + "description": "", + "lat": 45.2574251, + "lng": 19.8343345, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2574251,19.8343345", + "smoking": "none" + }, + { + "id": "smoke-10ad4981", + "name": "Macchiato Liman", + "description": "", + "lat": 45.2396011, + "lng": 19.8379077, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2396011,19.8379077", + "smoking": "alternative" + }, + { + "id": "smoke-0d9b57a6", + "name": "Maro's Pastelaria", + "description": "", + "lat": 45.2554392, + "lng": 19.8364141, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2554392,19.8364141", + "smoking": "none" + }, + { + "id": "smoke-15b3efe4", + "name": "Mercator Center", + "description": "", + "lat": 45.2430556, + "lng": 19.8408333, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2430556,19.8408333", + "smoking": "none" + }, + { + "id": "smoke-2799aade", + "name": "Mipl", + "description": "", + "lat": 45.2562713, + "lng": 19.842266, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2562713,19.842266", + "smoking": "none" + }, + { + "id": "smoke-de8ebda6", + "name": "Moritz Eis Belgrade", + "description": "", + "lat": 44.8173141, + "lng": 20.4553407, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8173141,20.4553407", + "smoking": "none" + }, + { + "id": "smoke-8471ebb2", + "name": "Nekrasova The Bar", + "description": "", + "lat": 44.8053527, + "lng": 20.4735437, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8053527,20.4735437", + "smoking": "alternative" + }, + { + "id": "smoke-e7c7ad96", + "name": "Nevski Hotel", + "description": "", + "lat": 44.8200496, + "lng": 20.4685502, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8200496,20.4685502", + "smoking": "none" + }, + { + "id": "smoke-e435a1cb", + "name": "OVO bistro", + "description": "", + "lat": 44.8142784, + "lng": 20.4677872, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8142784,20.4677872", + "smoking": "none" + }, + { + "id": "smoke-7fcca112", + "name": "Organic factory", + "description": "", + "lat": 45.2399028, + "lng": 19.8373791, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2399028,19.8373791", + "smoking": "none" + }, + { + "id": "smoke-a435b44b", + "name": "Pane E Vino, WEST 65", + "description": "", + "lat": 44.8125021, + "lng": 20.3997288, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8125021,20.3997288", + "smoking": "none" + }, + { + "id": "smoke-d49593e5", + "name": "Panuša Palačinkarnica", + "description": "", + "lat": 45.2546316, + "lng": 19.831763, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2546316,19.831763", + "smoking": "none" + }, + { + "id": "smoke-ce74d953", + "name": "Paradise food", + "description": "", + "lat": 44.7992946, + "lng": 20.4722345, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7992946,20.4722345", + "smoking": "none" + }, + { + "id": "smoke-fc68cb69", + "name": "Petrus Caffe", + "description": "", + "lat": 45.2543649, + "lng": 19.84639, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2543649,19.84639", + "smoking": "alternative" + }, + { + "id": "smoke-3e4c67f4", + "name": "Pietra Pizzeria & Cocktail Bar", + "description": "", + "lat": 44.8053314, + "lng": 20.4732625, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8053314,20.4732625", + "smoking": "none" + }, + { + "id": "smoke-1db2ddde", + "name": "Pizzeria Adrijana", + "description": "", + "lat": 45.2558334, + "lng": 19.8463962, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2558334,19.8463962", + "smoking": "alternative" + }, + { + "id": "smoke-fd29a3df", + "name": "Poslastičarnica Anči Kolači", + "description": "", + "lat": 44.8728741, + "lng": 20.6549366, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8728741,20.6549366", + "smoking": "none" + }, + { + "id": "smoke-7fa0be13", + "name": "Poslastičarnica Šuma (The Forest)", + "description": "", + "lat": 44.8171408, + "lng": 20.4634805, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171408,20.4634805", + "smoking": "none" + }, + { + "id": "smoke-3e1d3eb0", + "name": "Pričica Coffee Bar", + "description": "", + "lat": 44.8145846, + "lng": 20.4509601, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8145846,20.4509601", + "smoking": "alternative" + }, + { + "id": "smoke-8bf965c8", + "name": "Promenada", + "description": "", + "lat": 45.2444471, + "lng": 19.8424522, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2444471,19.8424522", + "smoking": "none" + }, + { + "id": "smoke-eb3a40fb", + "name": "Pržionica kafe Kafograf", + "description": "", + "lat": 44.8171929, + "lng": 20.4740927, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171929,20.4740927", + "smoking": "none" + }, + { + "id": "smoke-c6edfe35", + "name": "Radio Caffe", + "description": "", + "lat": 44.6206967, + "lng": 21.1832036, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.6206967,21.1832036", + "smoking": "alternative" + }, + { + "id": "smoke-a14637bc", + "name": "Rai Urban Vege", + "description": "", + "lat": 44.8265836, + "lng": 20.4565475, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8265836,20.4565475", + "smoking": "alternative" + }, + { + "id": "smoke-1a2282e3", + "name": "Rice Kings Sutlijaš bar", + "description": "", + "lat": 44.8162582, + "lng": 20.4563426, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8162582,20.4563426", + "smoking": "alternative" + }, + { + "id": "smoke-d95462d2", + "name": "Rosetto", + "description": "", + "lat": 45.240711, + "lng": 19.8129023, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.240711,19.8129023", + "smoking": "alternative" + }, + { + "id": "smoke-2d771251", + "name": "S N I P P S T E R", + "description": "", + "lat": 45.256868, + "lng": 19.8195656, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.256868,19.8195656", + "smoking": "alternative" + }, + { + "id": "smoke-04e69d21", + "name": "SMASH BURGERS", + "description": "", + "lat": 44.8149278, + "lng": 20.475142, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8149278,20.475142", + "smoking": "none" + }, + { + "id": "smoke-755dfc52", + "name": "Saint Ten Hotel", + "description": "", + "lat": 44.8013135, + "lng": 20.4671585, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8013135,20.4671585", + "smoking": "alternative" + }, + { + "id": "smoke-6fbc5c6e", + "name": "Salon de thé by Small Tree", + "description": "", + "lat": 44.8012702, + "lng": 20.4671757, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8012702,20.4671757", + "smoking": "none" + }, + { + "id": "smoke-1c299a2d", + "name": "Samo Dobra Kafa", + "description": "", + "lat": 44.7582055, + "lng": 19.6921476, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7582055,19.6921476", + "smoking": "none" + }, + { + "id": "smoke-365542a3", + "name": "Sendvič store Novi Beograd", + "description": "", + "lat": 44.8126819, + "lng": 20.3909278, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126819,20.3909278", + "smoking": "alternative" + }, + { + "id": "smoke-654e013d", + "name": "Sheraton Novi Sad", + "description": "", + "lat": 45.2481158, + "lng": 19.8162036, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2481158,19.8162036", + "smoking": "none" + }, + { + "id": "smoke-52384571", + "name": "Sojer Caffe Bar", + "description": "", + "lat": 44.6204283, + "lng": 21.1836508, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.6204283,21.1836508", + "smoking": "none" + }, + { + "id": "smoke-32488856", + "name": "Space Land", + "description": "", + "lat": 45.383028, + "lng": 20.3942825, + "category": "", + "city": "zrenjanin", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.383028,20.3942825", + "smoking": "none" + }, + { + "id": "smoke-d81f2560", + "name": "Starbucks Rajićeva", + "description": "", + "lat": 44.8199455, + "lng": 20.4539868, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8199455,20.4539868", + "smoking": "none" + }, + { + "id": "smoke-57949a78", + "name": "Stories", + "description": "", + "lat": 44.8232058, + "lng": 20.4555566, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8232058,20.4555566", + "smoking": "none" + }, + { + "id": "smoke-70eb2ba7", + "name": "Sunshine bagel", + "description": "", + "lat": 44.8055991, + "lng": 20.4660427, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8055991,20.4660427", + "smoking": "alternative" + }, + { + "id": "smoke-514bfb73", + "name": "Sweet & green", + "description": "", + "lat": 45.2559413, + "lng": 19.8467556, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2559413,19.8467556", + "smoking": "none" + }, + { + "id": "smoke-88fc0768", + "name": "TAO Thai restoran", + "description": "", + "lat": 44.8072842, + "lng": 20.4670377, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8072842,20.4670377", + "smoking": "alternative" + }, + { + "id": "smoke-42515a83", + "name": "Thyme - StreetFood & Breakfast", + "description": "", + "lat": 44.839264, + "lng": 20.4158154, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.839264,20.4158154", + "smoking": "none" + }, + { + "id": "smoke-e7f2cf1d", + "name": "Tokio Sushi", + "description": "", + "lat": 45.2574002, + "lng": 19.8475502, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2574002,19.8475502", + "smoking": "alternative" + }, + { + "id": "smoke-7f8eee6e", + "name": "Toto Healthy Street Food", + "description": "", + "lat": 44.8169267, + "lng": 20.4590406, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8169267,20.4590406", + "smoking": "none" + }, + { + "id": "smoke-71dcd413", + "name": "Valentina i Karanfil", + "description": "", + "lat": 44.8222945, + "lng": 20.4564396, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8222945,20.4564396", + "smoking": "none" + }, + { + "id": "smoke-9a8c9a3c", + "name": "VegANGELov", + "description": "", + "lat": 44.8200707, + "lng": 20.4622201, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8200707,20.4622201", + "smoking": "none" + }, + { + "id": "smoke-b1b459d2", + "name": "Vege Vegan", + "description": "", + "lat": 45.2539024, + "lng": 19.8397541, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2539024,19.8397541", + "smoking": "none" + }, + { + "id": "smoke-f9b61516", + "name": "Waffle Heart", + "description": "", + "lat": 44.7988284, + "lng": 20.4776707, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7988284,20.4776707", + "smoking": "none" + }, + { + "id": "smoke-7d3b5a53", + "name": "Walter Promenada", + "description": "", + "lat": 45.2445524, + "lng": 19.8419568, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2445524,19.8419568", + "smoking": "alternative" + }, + { + "id": "smoke-e661b7f8", + "name": "Way Cup kafeterija", + "description": "", + "lat": 44.7922926, + "lng": 20.4995532, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7922926,20.4995532", + "smoking": "none" + }, + { + "id": "smoke-5842db29", + "name": "X.WANG's kitchen", + "description": "", + "lat": 44.8128667, + "lng": 20.4582251, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8128667,20.4582251", + "smoking": "none" + }, + { + "id": "smoke-71c785c1", + "name": "Zenit books", + "description": "", + "lat": 45.2570533, + "lng": 19.8429177, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2570533,19.8429177", + "smoking": "alternative" + }, + { + "id": "smoke-a1e36b0a", + "name": "jazzayoga", + "description": "", + "lat": 44.8083424, + "lng": 20.469175, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8083424,20.469175", + "smoking": "none" + }, + { + "id": "smoke-99f63d10", + "name": "Ćao Šećeru", + "description": "", + "lat": 44.824206, + "lng": 20.4573513, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.824206,20.4573513", + "smoking": "alternative" + }, + { + "id": "smoke-4f9f9ab1", + "name": "Ćevabdžinica Savčić Vračar", + "description": "", + "lat": 44.8041093, + "lng": 20.4665885, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8041093,20.4665885", + "smoking": "none" + }, + { + "id": "smoke-a1419fa3", + "name": "Šećernema", + "description": "", + "lat": 45.2489528, + "lng": 19.8392648, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2489528,19.8392648", + "smoking": "none" + }, + { + "id": "smoke-dc93d020", + "name": "Štrik kafe knjižara", + "description": "", + "lat": 44.8126898, + "lng": 20.4646965, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126898,20.4646965", + "smoking": "none" + }, + { + "id": "smoke-8d61d85b", + "name": "Кофилин Kofilin", + "description": "", + "lat": 44.823363, + "lng": 20.4605254, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.823363,20.4605254", + "smoking": "none" + }, + { + "id": "smoke-733524a3", + "name": "Орашац", + "description": "", + "lat": 44.8037935, + "lng": 20.478627, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8037935,20.478627", + "smoking": "alternative" + }, + { + "id": "smoke-e0531e6f", + "name": "Отель Москва", + "description": "", + "lat": 44.8130277, + "lng": 20.4604305, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8130277,20.4604305", + "smoking": "alternative" + }, + { + "id": "smoke-f0a44780", + "name": "Плава Фрајла", + "description": "", + "lat": 45.2475454, + "lng": 19.8439561, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2475454,19.8439561", + "smoking": "alternative" + }, + { + "id": "smoke-7df87d56", + "name": "Сценарио", + "description": "", + "lat": 45.2517751, + "lng": 19.8247352, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2517751,19.8247352", + "smoking": "alternative" + } + ] +} \ No newline at end of file diff --git a/lib/data/place.dart b/lib/data/place.dart index 524d0f1..3c2732e 100644 --- a/lib/data/place.dart +++ b/lib/data/place.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; /// A relocant-run business from the stats.srb.guide catalogue. @@ -20,6 +22,11 @@ class Place { /// Google Maps link for turn-by-turn directions. final String mapUrl; + /// Smoking policy when it is known: `none` for a venue where smoking is + /// banned, `alternative` where only smokeless devices are allowed. Empty for + /// the businesses catalogue, which does not track it. + final String smoking; + const Place({ required this.id, required this.name, @@ -30,6 +37,7 @@ class Place { required this.city, required this.opstina, required this.mapUrl, + this.smoking = '', }); factory Place.fromJson(Map json) => Place( @@ -42,6 +50,20 @@ class Place { city: (json['city'] ?? '') as String, opstina: (json['opstina'] ?? '') as String, mapUrl: (json['mapUrl'] ?? '') as String, + smoking: (json['smoking'] ?? '') as String, + ); + + Place withSmoking(String value) => Place( + id: id, + name: name, + description: description, + lat: lat, + lng: lng, + category: category, + city: city, + opstina: opstina, + mapUrl: mapUrl, + smoking: value, ); bool get isValid => name.isNotEmpty && lat != 0 && lng != 0; @@ -78,6 +100,50 @@ class PlaceCatalogue { ); } + /// Folds a second catalogue into this one. + /// + /// The lists overlap: a handful of relocant-run cafés are also on the + /// non-smoking map. Those are matched by name and proximity and marked in + /// place, so the map does not end up with two pins on the same doorstep. + PlaceCatalogue mergedWith(PlaceCatalogue other) { + if (other.places.isEmpty) return this; + + final List merged = List.of(places); + final Map> byName = >{}; + for (int i = 0; i < merged.length; i++) { + byName.putIfAbsent(_nameKey(merged[i].name), () => []).add(i); + } + + for (final Place p in other.places) { + int? at; + for (final int i in byName[_nameKey(p.name)] ?? const []) { + if (_metresBetween(merged[i], p) <= 250) { + at = i; + break; + } + } + if (at == null) { + merged.add(p); + } else if (merged[at].smoking.isEmpty && p.smoking.isNotEmpty) { + merged[at] = merged[at].withSmoking(p.smoking); + } + } + + return PlaceCatalogue( + source: source, + syncedAt: syncedAt, + places: merged, + ); + } + + /// Smoking policies present, ordered as the filter row shows them. + List get smokingPolicies { + const List order = ['none', 'alternative']; + return order + .where((String v) => places.any((Place p) => p.smoking == v)) + .toList(); + } + /// Categories present, ordered by how many places use them. List get categories { final Map counts = {}; @@ -91,6 +157,20 @@ class PlaceCatalogue { } } +/// Name reduced to letters and digits, so `Kaži Važi` and `Kazi Vazi!` are one +/// venue rather than two. +String _nameKey(String name) => + name.toLowerCase().replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ''); + +/// Straight-line distance in metres. Fine at this scale, and no trigonometry +/// beyond one cosine. +double _metresBetween(Place a, Place b) { + final double dLat = (a.lat - b.lat) * 111320; + final double dLng = + (a.lng - b.lng) * 111320 * math.cos(a.lat * math.pi / 180); + return math.sqrt(dLat * dLat + dLng * dLng); +} + /// Icon and colour per catalogue category, so the map reads at a glance. ({IconData icon, Color color}) placeStyle(String category) { switch (category) { @@ -127,5 +207,23 @@ class PlaceCatalogue { } } +/// Icon and colour for a smoking policy. +({IconData icon, Color color}) smokingStyle(String smoking) => + smoking == 'alternative' + ? (icon: Icons.air, color: const Color(0xFF0C8599)) + : (icon: Icons.smoke_free, color: const Color(0xFF2F9E44)); + +/// How a place is drawn on the map and in the list. +/// +/// The non-smoking map carries no category, so those venues would otherwise +/// all be grey pins; their policy is the useful thing to show instead. +({IconData icon, Color color}) placeMarkerStyle(Place place) => + place.category.isEmpty && place.smoking.isNotEmpty + ? smokingStyle(place.smoking) + : placeStyle(place.category); + /// Localization key for a category label. String placeCategoryKey(String category) => 'place_cat_$category'; + +/// Localization key for a smoking policy label. +String placeSmokingKey(String smoking) => 'place_smoking_$smoking'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f4ad1dc..8773164 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -10,7 +10,6 @@ "need_make_visa_run_by": "Need to make visa run by", "create_whiteboard": "Create whiteboard", "flat_tax_calculator": "Flat tax calculator", - "maps": "Maps", "guide": "Guide", "tg_chats": "Telegram chats", "settings": "Settings", @@ -181,12 +180,12 @@ "place_cat_sport": "Sport", "place_cat_misc": "Misc", "places": "Places", - "places_subtitle": "Relocant-run businesses on the map", + "places_subtitle": "Relocant-run businesses and non-smoking venues", "map_view": "Map", "list_view": "List", "open_in_maps": "Open in maps", "search_places": "Search by name", - "places_source": "Data: stats.srb.guide · tiles © OpenStreetMap", + "places_source": "Data: stats.srb.guide and lokalibezdima.rs · tiles © OpenStreetMap", "no_places_found": "Nothing found", "places_count": "places", "reset_filters": "Reset", @@ -216,5 +215,9 @@ "overstay_body": "The allowance has run out. Overstaying means a fine and possibly an entry ban — talk to a lawyer.", "how_days_counted": "How days are counted", "how_days_counted_body": "The entry day and the exit day both count: arrive on 1 January, leave on 2 January, and that is two days.\n\nUnder a 30-days-per-entry rule the counter restarts every time you cross the border. Under a rolling window it does not — every day spent in the country during the window counts, however many trips it took.", - "calc_disclaimer": "A guide, not legal advice. The officer at the border decides." + "calc_disclaimer": "A guide, not legal advice. The officer at the border decides.", + "places_group": "Places and transport", + "place_smoking_none": "Non-smoking", + "place_smoking_alternative": "Smokeless only", + "exchange_offices": "Exchange offices nearby" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f8e723c..c1be319 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -10,7 +10,6 @@ "need_make_visa_run_by": "Нужно сделать визаран до", "create_whiteboard": "Создание белого картона", "flat_tax_calculator": "Калькулятор паушального налога", - "maps": "Карты", "guide": "Гайд", "tg_chats": "Телеграм чаты", "settings": "Настройки", @@ -180,12 +179,12 @@ "place_cat_sport": "Спорт", "place_cat_misc": "Разное", "places": "Места", - "places_subtitle": "Бизнесы релокантов на карте", + "places_subtitle": "Места релокантов и заведения без курения", "map_view": "Карта", "list_view": "Список", "open_in_maps": "Открыть в картах", "search_places": "Поиск по названию", - "places_source": "Данные: stats.srb.guide · тайлы © OpenStreetMap", + "places_source": "Данные: stats.srb.guide и lokalibezdima.rs · тайлы © OpenStreetMap", "no_places_found": "Ничего не найдено", "places_count": "мест", "reset_filters": "Сбросить", @@ -215,5 +214,9 @@ "overstay_body": "Безвизовый срок закончился. Просрочка — это штраф и возможный запрет на въезд, лучше обратиться к юристу.", "how_days_counted": "Как считаются дни", "how_days_counted_body": "День въезда и день выезда считаются оба: въехали 1 января, выехали 2 января — это два дня.\n\nПри правиле «30 дней на въезд» счётчик обнуляется при каждом пересечении границы. При скользящем окне — нет: считаются все дни в стране за это окно, сколько бы поездок ни было.", - "calc_disclaimer": "Это ориентир, а не юридическая консультация. Решение принимает офицер на границе." + "calc_disclaimer": "Это ориентир, а не юридическая консультация. Решение принимает офицер на границе.", + "places_group": "Места и транспорт", + "place_smoking_none": "Без курения", + "place_smoking_alternative": "Бездымная альтернатива", + "exchange_offices": "Обменники рядом" } diff --git a/lib/screens/map.dart b/lib/screens/map.dart deleted file mode 100644 index dfaa8ca..0000000 --- a/lib/screens/map.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:srbguide/localization/app_localizations.dart'; -import 'package:srbguide/service/url_launcher_helper.dart'; -import 'package:srbguide/widget/app_bar.dart'; -import 'package:srbguide/widget/themed/themed_icon.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -class MapScreen extends StatefulWidget { - const MapScreen({super.key}); - - @override - State createState() => _MapScreenState(); -} - -class _MapScreenState extends State { - List> locations = []; - late Map selectedLocation = {}; - late final WebViewController _webViewController; - late String selectedUrl = ""; - - @override - void initState() { - super.initState(); - _webViewController = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate( - NavigationDelegate( - // Map providers redirect to intent:// / geo:// to hand the route off - // to a native app. The WebView can't load those schemes and shows - // ERR_UNKNOWN_URL_SCHEME, so send them to the platform instead. - onNavigationRequest: (NavigationRequest request) { - final Uri? uri = Uri.tryParse(request.url); - if (uri != null && uri.scheme != 'http' && uri.scheme != 'https') { - UrlLauncherHelper.launchURL(request.url); - return NavigationDecision.prevent; - } - return NavigationDecision.navigate; - }, - ), - ); - loadLocations(); - } - - Future loadLocations() async { - String data = await DefaultAssetBundle.of(context) - .loadString('assets/data/locations.json'); - setState(() { - locations = List>.from(json.decode(data)); - if (locations.isNotEmpty) { - selectedLocation = locations[0]; - selectedUrl = selectedLocation['url'] ?? ''; - } - }); - if (selectedUrl.isNotEmpty) { - await _webViewController.loadRequest(Uri.parse(selectedUrl)); - } - } - - @override - Widget build(BuildContext context) { - if (locations.isEmpty) { - return Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return Scaffold( - appBar: CustomAppBar( - title: AppLocalizations.of(context)!.translate('maps'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: double.infinity, - child: DropdownButton>( - value: selectedLocation, - onChanged: (newValue) { - setState(() { - selectedLocation = newValue!; - selectedUrl = newValue['url'] ?? ''; - }); - if (selectedUrl.isNotEmpty) { - _webViewController.loadRequest(Uri.parse(selectedUrl)); - } - }, - items: locations.map>>( - (location) { - return DropdownMenuItem>( - value: location, - child: Row( - children: [ - ThemedIcon( - iconPath: - 'assets/icons_24x24/${location['iconPath']}', - size: 24.0, - ), - SizedBox(width: 8), - Text(location['title'] ?? ''), - ], - ), - ); - }, - ).toList(), - icon: ThemedIcon( - iconPath: 'assets/icons_24x24/caret-down.png', - size: 24.0, - ), - ), - ), - SizedBox(height: 10.0), - Expanded( - child: selectedUrl.isNotEmpty - ? WebViewWidget(controller: _webViewController) - : Center( - child: Text('No URL selected'), - ), - ), - Card( - margin: EdgeInsets.all(8.0), - child: ListTile( - contentPadding: EdgeInsets.symmetric(horizontal: 16.0), - title: Center( - child: Text( - AppLocalizations.of(context)! - .translate('open_selected_map'), - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - onTap: () { - if (selectedUrl.isNotEmpty) { - UrlLauncherHelper.launchURL(selectedUrl); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No URL selected')), - ); - } - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/places.dart b/lib/screens/places.dart index 2348cb2..53d7cc8 100644 --- a/lib/screens/places.dart +++ b/lib/screens/places.dart @@ -10,11 +10,14 @@ import 'package:srbguide/localization/app_localizations.dart'; import 'package:srbguide/service/url_launcher_helper.dart'; import 'package:srbguide/widget/guide_tiles.dart'; -/// Map of relocant-run businesses. +/// Map of relocant-run businesses and non-smoking venues. /// /// Tiles come from OpenStreetMap, which needs no API key or billing account — -/// the Google Maps SDK would need both. The catalogue itself is bundled, so the +/// the Google Maps SDK would need both. Both catalogues are bundled, so the /// list works with no connection and only the tiles need one. +/// +/// This screen replaced the old "Maps" screen, which was a dropdown of Google +/// My Maps links in a WebView: the venues it pointed at are now pins here. class PlacesScreen extends StatefulWidget { const PlacesScreen({super.key}); @@ -29,8 +32,14 @@ class _PlacesScreenState extends State { final MapController _map = MapController(); final TextEditingController _search = TextEditingController(); + /// Google Maps search the old maps screen linked to. There is no bundled + /// list of exchange offices — the search finds the ones that are open now. + static const String _exchangeOfficesUrl = + 'https://www.google.com/maps/search/Мењачница'; + PlaceCatalogue _catalogue = PlaceCatalogue.empty; String? _category; + String? _smoking; bool _mapView = true; bool _loading = true; @@ -49,13 +58,13 @@ class _PlacesScreenState extends State { Future _load() async { try { - final String raw = await rootBundle.loadString('assets/data/places.json'); - final PlaceCatalogue catalogue = PlaceCatalogue.fromJson( - json.decode(raw) as Map, - ); + final PlaceCatalogue businesses = await _asset('places.json'); + // The non-smoking map is a separate feed with its own refresh cycle, so + // a failure there must not cost us the businesses. + final PlaceCatalogue smoking = await _asset('smoking.json'); if (!mounted) return; setState(() { - _catalogue = catalogue; + _catalogue = businesses.mergedWith(smoking); _loading = false; }); } catch (_) { @@ -64,10 +73,20 @@ class _PlacesScreenState extends State { } } + Future _asset(String name) async { + try { + final String raw = await rootBundle.loadString('assets/data/$name'); + return PlaceCatalogue.fromJson(json.decode(raw) as Map); + } catch (_) { + return PlaceCatalogue.empty; + } + } + List get _visible { final String q = _search.text.trim().toLowerCase(); return _catalogue.places.where((Place p) { if (_category != null && p.category != _category) return false; + if (_smoking != null && p.smoking != _smoking) return false; if (q.isNotEmpty && !p.searchIndex.contains(q)) return false; return true; }).toList(); @@ -98,6 +117,11 @@ class _PlacesScreenState extends State { icon: Icon(_mapView ? Icons.list : Icons.map_outlined), onPressed: () => setState(() => _mapView = !_mapView), ), + IconButton( + tooltip: l10n.translate('exchange_offices'), + icon: const Icon(Icons.currency_exchange), + onPressed: () => UrlLauncherHelper.launchURL(_exchangeOfficesUrl), + ), ], ), body: _loading @@ -135,8 +159,31 @@ class _PlacesScreenState extends State { padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text(l10n.translate('all')), - selected: _category == null, - onSelected: (_) => setState(() => _category = null), + selected: _category == null && _smoking == null, + onSelected: (_) => setState(() { + _category = null; + _smoking = null; + }), + ), + ), + // Smoking first: it is the one filter people come to the + // map with rather than browse by. + ..._catalogue.smokingPolicies.map( + (String v) => Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + avatar: Icon( + smokingStyle(v).icon, + size: 16, + color: smokingStyle(v).color, + ), + label: Text(l10n.translate(placeSmokingKey(v))), + selected: _smoking == v, + onSelected: (bool on) => setState(() { + _smoking = on ? v : null; + _category = null; + }), + ), ), ), ..._catalogue.categories.map( @@ -150,8 +197,10 @@ class _PlacesScreenState extends State { ), label: Text(l10n.translate(placeCategoryKey(c))), selected: _category == c, - onSelected: (bool on) => - setState(() => _category = on ? c : null), + onSelected: (bool on) => setState(() { + _category = on ? c : null; + _smoking = null; + }), ), ), ), @@ -260,7 +309,7 @@ class _Pin extends StatelessWidget { @override Widget build(BuildContext context) { - final ({IconData icon, Color color}) style = placeStyle(place.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(place); return GestureDetector( onTap: onTap, child: Container( @@ -306,7 +355,7 @@ class _ListView extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (BuildContext context, int i) { final Place p = places[i]; - final ({IconData icon, Color color}) style = placeStyle(p.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(p); return Card( child: InkWell( onTap: () => onTap(p), @@ -347,6 +396,10 @@ class _ListView extends StatelessWidget { ), ), ], + if (p.smoking.isNotEmpty) ...[ + const SizedBox(height: 6), + _SmokingBadge(smoking: p.smoking), + ], ], ), ), @@ -369,7 +422,7 @@ class _PlaceSheet extends StatelessWidget { Widget build(BuildContext context) { final ColorScheme scheme = Theme.of(context).colorScheme; final AppLocalizations l10n = AppLocalizations.of(context)!; - final ({IconData icon, Color color}) style = placeStyle(place.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(place); return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), @@ -415,6 +468,10 @@ class _PlaceSheet extends StatelessWidget { ), ], ), + if (place.smoking.isNotEmpty) ...[ + const SizedBox(height: 14), + _SmokingBadge(smoking: place.smoking), + ], if (place.description.isNotEmpty) ...[ const SizedBox(height: 14), Text( @@ -435,3 +492,39 @@ class _PlaceSheet extends StatelessWidget { ); } } + +/// Marks what the non-smoking map says about a venue. +class _SmokingBadge extends StatelessWidget { + final String smoking; + + const _SmokingBadge({required this.smoking}); + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + final ({IconData icon, Color color}) style = smokingStyle(smoking); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: style.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, size: 14, color: style.color), + const SizedBox(width: 5), + Text( + l10n.translate(placeSmokingKey(smoking)), + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: style.color, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/services.dart b/lib/screens/services.dart index c9d77c1..c2fdfae 100644 --- a/lib/screens/services.dart +++ b/lib/screens/services.dart @@ -6,7 +6,6 @@ import 'package:srbguide/screens/calculator.dart'; import 'package:srbguide/screens/deadlines.dart'; import 'package:srbguide/screens/exchange_rate.dart'; import 'package:srbguide/screens/journey.dart'; -import 'package:srbguide/screens/map.dart'; import 'package:srbguide/screens/places.dart'; import 'package:srbguide/screens/settings.dart'; import 'package:srbguide/screens/tg_chats.dart'; @@ -14,7 +13,7 @@ import 'package:srbguide/screens/trains.dart'; import 'package:srbguide/screens/white_cardboard.dart'; import 'package:srbguide/service/url_launcher_helper.dart'; -/// Everything that is not the guide itself: calculators, rates, maps, chats +/// Everything that is not the guide itself: calculators, rates, places, chats /// and the external links that used to live in the navigation drawer. class ServicesScreen extends StatelessWidget { const ServicesScreen({super.key}); @@ -63,11 +62,6 @@ class ServicesScreen extends StatelessWidget { title: l10n.translate('places'), push: (_) => const PlacesScreen(), ), - _Entry( - icon: Icons.map_outlined, - title: l10n.translate('maps'), - push: (_) => const MapScreen(), - ), _Entry( icon: Icons.train_outlined, title: l10n.translate('trains'), @@ -113,7 +107,7 @@ class ServicesScreen extends StatelessWidget { delegate: SliverChildListDelegate([ _Group(title: l10n.translate('quick_actions'), entries: tools), const SizedBox(height: 20), - _Group(title: l10n.translate('maps'), entries: places), + _Group(title: l10n.translate('places_group'), entries: places), const SizedBox(height: 20), _Group(title: l10n.translate('help'), entries: about), ]), diff --git a/pubspec.lock b/pubspec.lock index 25d395c..14fb0a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -922,38 +922,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 - url: "https://pub.dev" - source: hosted - version: "3.26.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a08951..b4fa4b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: cupertino_icons: ^1.0.2 xml: ^6.5.0 docx_template: ^0.4.0 - webview_flutter: ^4.14.0 url_launcher: ^6.3.1 shared_preferences: ^2.5.3 add_2_calendar: ^3.1.1 diff --git a/test/place_merge_test.dart b/test/place_merge_test.dart new file mode 100644 index 0000000..0294d85 --- /dev/null +++ b/test/place_merge_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:srbguide/data/place.dart'; + +Place _place( + String name, { + double lat = 44.8125, + double lng = 20.4612, + String category = 'food', + String smoking = '', +}) => + Place( + id: name, + name: name, + description: '', + lat: lat, + lng: lng, + category: category, + city: 'beograd', + opstina: '', + mapUrl: '', + smoking: smoking, + ); + +PlaceCatalogue _catalogue(List places) => PlaceCatalogue( + source: 'test', + syncedAt: null, + places: places, + ); + +void main() { + group('mergedWith', () { + test('marks the same venue instead of adding a second pin', () { + final PlaceCatalogue merged = + _catalogue([_place('Le Bol')]).mergedWith(_catalogue([ + _place('Le Bol', category: '', smoking: 'none'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.category, 'food'); + expect(merged.places.single.smoking, 'none'); + }); + + test('matches through punctuation and case', () { + final PlaceCatalogue merged = _catalogue([_place('Kaži Važi')]) + .mergedWith(_catalogue([ + _place('KAŽI VAŽI!', category: '', smoking: 'alternative'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.smoking, 'alternative'); + }); + + test('keeps a namesake in another city as its own place', () { + // Same name, ~90 km away: two different venues. + final PlaceCatalogue merged = + _catalogue([_place('Kofilin')]).mergedWith(_catalogue([ + _place( + 'Kofilin', + lat: 45.2551, + lng: 19.8452, + category: '', + smoking: 'none', + ), + ])); + + expect(merged.places, hasLength(2)); + expect(merged.places.first.smoking, isEmpty); + }); + + test('never downgrades a policy already on the catalogue', () { + final PlaceCatalogue merged = + _catalogue([_place('Gurme', smoking: 'none')]) + .mergedWith(_catalogue([ + _place('Gurme', category: '', smoking: 'alternative'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.smoking, 'none'); + }); + + test('keeps the source and timestamp of the base catalogue', () { + final PlaceCatalogue base = PlaceCatalogue( + source: 'https://stats.srb.guide/map', + syncedAt: DateTime.utc(2026, 9, 5), + places: [_place('Sonder')], + ); + final PlaceCatalogue merged = base + .mergedWith(_catalogue([_place('Ananda', smoking: 'none')])); + + expect(merged.source, 'https://stats.srb.guide/map'); + expect(merged.syncedAt, DateTime.utc(2026, 9, 5)); + expect(merged.places, hasLength(2)); + }); + + test('an empty second catalogue changes nothing', () { + final PlaceCatalogue base = _catalogue([_place('Sonder')]); + expect(base.mergedWith(PlaceCatalogue.empty), same(base)); + }); + }); + + group('smokingPolicies', () { + test('lists only the policies present, strictest first', () { + final PlaceCatalogue catalogue = _catalogue([ + _place('a', smoking: 'alternative'), + _place('b'), + _place('c', smoking: 'none'), + ]); + expect(catalogue.smokingPolicies, ['none', 'alternative']); + }); + + test('is empty for a catalogue that tracks no policy', () { + expect(_catalogue([_place('a')]).smokingPolicies, isEmpty); + }); + }); + + group('placeMarkerStyle', () { + test('falls back to the smoking policy when there is no category', () { + final Place p = _place('a', category: '', smoking: 'none'); + expect(placeMarkerStyle(p), smokingStyle('none')); + }); + + test('keeps the category icon when the venue has one', () { + final Place p = _place('a', smoking: 'none'); + expect(placeMarkerStyle(p), placeStyle('food')); + }); + }); +} diff --git a/tool/sync_smoking.dart b/tool/sync_smoking.dart new file mode 100644 index 0000000..1cd2e1b --- /dev/null +++ b/tool/sync_smoking.dart @@ -0,0 +1,190 @@ +// Regenerates `assets/data/smoking.json` from the "Lokali bez dima" map. +// +// dart run tool/sync_smoking.dart +// dart run tool/sync_smoking.dart --out assets/data/smoking.json +// +// The non-smoking venues used to be a Google My Maps link opened in a WebView. +// My Maps exports KML for free, so the points are pulled out here and bundled +// like the rest of the catalogue: the places screen then shows them as pins +// next to the relocant businesses, offline and without loading Google. +// +// Two layers are published on that map, and they mean different things: +// Nepušački lokal — smoking is banned outright -> smoking: none +// Bezdimna alternativa — only smokeless devices allowed -> smoking: alternative + +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:xml/xml.dart'; + +const String kMapId = '1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo'; +const String kSource = 'https://www.google.com/maps/d/viewer?mid=$kMapId'; +const String kUpstream = 'https://lokalibezdima.rs'; +const String kDefaultOut = 'assets/data/smoking.json'; + +/// Layer name on the map -> the value stored on each place. +const Map kLayers = { + 'Nepušački lokal': 'none', + 'Bezdimna alternativa': 'alternative', +}; + +/// City centres used to label a point, with the radius that still counts as +/// that city. Only for the city filter — a point outside them all keeps `''`. +const List<({String slug, double lat, double lng, double km})> kCities = + <({String slug, double lat, double lng, double km})>[ + (slug: 'beograd', lat: 44.8125, lng: 20.4612, km: 25), + (slug: 'novi-sad', lat: 45.2551, lng: 19.8452, km: 20), + (slug: 'nis', lat: 43.3209, lng: 21.8958, km: 15), + (slug: 'subotica', lat: 46.1000, lng: 19.6650, km: 15), + (slug: 'kragujevac', lat: 44.0128, lng: 20.9114, km: 15), + (slug: 'zrenjanin', lat: 45.3836, lng: 20.3819, km: 15), + (slug: 'pancevo', lat: 44.8708, lng: 20.6403, km: 12), + (slug: 'cacak', lat: 43.8914, lng: 20.3497, km: 15), +]; + +Future main(List args) async { + final String out = _argValue(args, '--out') ?? kDefaultOut; + + stdout.writeln('Fetching the KML export of $kSource …'); + final String kml = await _get( + 'https://www.google.com/maps/d/kml?mid=$kMapId&forcekml=1', + ); + + final List> places = _extractPlaces(kml); + if (places.isEmpty) { + stderr.writeln('FAIL: no placemarks found — the map or its layers changed'); + exit(1); + } + + final Map payload = { + 'source': kSource, + 'upstream': kUpstream, + 'syncedAt': DateTime.now().toUtc().toIso8601String(), + 'places': places, + }; + + final File file = File(out); + await file.parent.create(recursive: true); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(payload), + encoding: utf8, + ); + + stdout.writeln('Wrote $out — ${places.length} venues'); + for (final MapEntry layer in kLayers.entries) { + final int n = places.where((Map p) { + return p['smoking'] == layer.value; + }).length; + stdout.writeln(' ${n.toString().padLeft(4)} ${layer.key}'); + } +} + +/// Reads the placemarks of every known layer out of the KML document. +List> _extractPlaces(String kml) { + final XmlDocument doc = XmlDocument.parse(kml); + final List> places = >[]; + final Set seen = {}; + + for (final XmlElement folder in doc.findAllElements('Folder')) { + final String layer = _text(folder, 'name'); + final String? smoking = kLayers[layer]; + if (smoking == null) { + stderr.writeln('Skipping unknown layer "$layer"'); + continue; + } + + for (final XmlElement placemark in folder.findElements('Placemark')) { + final String name = _text(placemark, 'name'); + final ({double lat, double lng})? point = _point(placemark); + if (name.isEmpty || point == null) continue; + + // A venue listed on both layers keeps the stricter one, which is the + // order kLayers is walked in. + final String id = 'smoke-${_hash('$name|${point.lat}|${point.lng}')}'; + if (!seen.add(id)) continue; + + places.add({ + 'id': id, + 'name': name, + 'description': _text(placemark, 'description'), + 'lat': point.lat, + 'lng': point.lng, + // The map says nothing about what kind of venue this is, and guessing + // "food" would put cafés under a filter they may not belong in. + 'category': '', + 'city': _city(point.lat, point.lng), + 'opstina': '', + 'mapUrl': 'https://www.google.com/maps/search/?api=1' + '&query=${point.lat},${point.lng}', + 'smoking': smoking, + }); + } + } + + places.sort((Map a, Map b) => + (a['name']! as String).compareTo(b['name']! as String)); + return places; +} + +/// `lng,lat,alt` of a Point placemark. +({double lat, double lng})? _point(XmlElement placemark) { + final Iterable points = placemark.findAllElements('Point'); + if (points.isEmpty) return null; + final List parts = + _text(points.first, 'coordinates').split(',').map((String s) { + return s.trim(); + }).toList(); + if (parts.length < 2) return null; + + final double? lng = double.tryParse(parts[0]); + final double? lat = double.tryParse(parts[1]); + if (lat == null || lng == null) return null; + // Serbia plus a margin; anything else is a stray pin, not a venue. + if (lat < 41 || lat > 47 || lng < 18 || lng > 23.5) return null; + return (lat: lat, lng: lng); +} + +String _text(XmlElement parent, String tag) { + final Iterable found = parent.findElements(tag); + return found.isEmpty ? '' : found.first.innerText.trim(); +} + +String _city(double lat, double lng) { + for (final ({String slug, double lat, double lng, double km}) c in kCities) { + final double dLat = (lat - c.lat) * 111.32; + final double dLng = (lng - c.lng) * 78.6; + if (dLat * dLat + dLng * dLng <= c.km * c.km) return c.slug; + } + return ''; +} + +/// FNV-1a, so a venue keeps its id across syncs as long as it does not move. +String _hash(String value) { + int hash = 0x811c9dc5; + for (final int unit in utf8.encode(value)) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} + +Future _get(String url) async { + final http.Response response = await http.get( + Uri.parse(url), + headers: const { + 'User-Agent': + 'srbguide-app-sync/1.0 (+https://github.com/ialakey/srbguide)', + 'Accept-Language': 'sr,en;q=0.8', + }, + ).timeout(const Duration(seconds: 60)); + if (response.statusCode != 200) { + throw StateError('HTTP ${response.statusCode} for $url'); + } + return utf8.decode(response.bodyBytes); +} + +String? _argValue(List args, String flag) { + final int i = args.indexOf(flag); + if (i == -1 || i + 1 >= args.length) return null; + return args[i + 1]; +} From 9cdbb0c055f2775d8cd56fa31abe3828b951349c Mon Sep 17 00:00:00 2001 From: ialakey Date: Sun, 6 Sep 2026 16:13:49 +0200 Subject: [PATCH 3/4] fix: match train stations on device, so Russian spellings work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a station in Russian answered with an alphabetical dump of the whole network: "Белград" returned Kemendin st, Altina st, DOLJEVAC and so on, which read as the search having stopped working. The lookup at w3.srbvoz.rs only understands Latin. Its own front end strips everything else out of the term before sending it, so a Cyrillic query arrives empty — and an empty term is answered with all 396 stations. The old code showed that response as the result. So the app now asks for that list once, the way the site itself does, caches it for a week and matches locally: - Cyrillic is transliterated and diacritics are folded, so "Ниш", "niš" and "nis" are one station; - each word is allowed one character of slack, because the two languages spell the same place differently (Белград/Beograd, Крагуевац/Kragujevac); - matching runs inside the name rather than as a prefix, so "centar" finds BEOGRAD CENTAR — which the server itself could not do. Also stops answering a network failure with "nothing found": the picker now says the lookup failed. The list is 396 rows and 15 KB, so this removes a request per keystroke as well. --- lib/screens/trains.dart | 39 ++++-- lib/service/srbijavoz_service.dart | 129 +++++++++++++++---- lib/utils/station_search.dart | 197 +++++++++++++++++++++++++++++ test/srbijavoz_live_test.dart | 15 +++ test/station_search_test.dart | 98 ++++++++++++++ 5 files changed, 440 insertions(+), 38 deletions(-) create mode 100644 lib/utils/station_search.dart create mode 100644 test/station_search_test.dart diff --git a/lib/screens/trains.dart b/lib/screens/trains.dart index 1deb5ce..c910862 100644 --- a/lib/screens/trains.dart +++ b/lib/screens/trains.dart @@ -574,6 +574,7 @@ class _StationPickerState extends State<_StationPicker> { List _recent = const []; Timer? _debounce; bool _loading = false; + String? _error; @override void initState() { @@ -598,9 +599,21 @@ class _StationPickerState extends State<_StationPicker> { setState(() => _loading = true); try { final List r = await _service.searchStations(value); - if (mounted) setState(() => _results = r); - } catch (_) { - if (mounted) setState(() => _results = const []); + if (mounted) { + setState(() { + _results = r; + _error = null; + }); + } + } catch (e) { + // The station list comes over the network the first time, so a failure + // here is "we could not look", not "there is no such station". + if (mounted) { + setState(() { + _results = const []; + _error = e.toString(); + }); + } } finally { if (mounted) setState(() => _loading = false); } @@ -666,14 +679,18 @@ class _StationPickerState extends State<_StationPicker> { child: shown.isEmpty ? Padding( padding: const EdgeInsets.symmetric(vertical: 28), - child: Center( - child: Text( - typing - ? l10n.translate('nothing_found') - : l10n.translate('search_station'), - style: TextStyle(color: scheme.onSurfaceVariant), - ), - ), + child: _error != null + ? _ErrorNote( + message: '${l10n.translate('load_error')}: $_error', + ) + : Center( + child: Text( + typing + ? l10n.translate('nothing_found') + : l10n.translate('search_station'), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), ) : ListView.separated( shrinkWrap: true, diff --git a/lib/service/srbijavoz_service.dart b/lib/service/srbijavoz_service.dart index d576581..5ff90df 100644 --- a/lib/service/srbijavoz_service.dart +++ b/lib/service/srbijavoz_service.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:srbguide/data/train.dart'; +import 'package:srbguide/utils/station_search.dart'; /// Reads the Serbian Railways timetable at w3.srbvoz.rs. /// @@ -14,6 +15,11 @@ import 'package:srbguide/data/train.dart'; /// therefore clean JSON; the timetables have to be read out of the result /// tables, which is why the row layouts are pinned down in one place here /// rather than scattered through the UI. +/// +/// The station lookup only understands Latin, so it is used the way the site's +/// own front end uses it for a term it cannot send — once, with no term, which +/// returns the whole network. Matching then happens on device, where a Russian +/// spelling can be transliterated. See `utils/station_search.dart`. class SrbijavozService { SrbijavozService._(); @@ -21,6 +27,12 @@ class SrbijavozService { static const String _base = 'https://w3.srbvoz.rs/redvoznje'; static const String _recentKey = 'recentTrainStations'; + static const String _stationsKey = 'trainStations'; + static const String _stationsAtKey = 'trainStationsFetchedAt'; + + /// The network barely changes, and a stale list still resolves to the same + /// codes, so this only guards against a station being added and never seen. + static const Duration _stationsTtl = Duration(days: 7); static const Map _headers = { 'User-Agent': @@ -28,22 +40,89 @@ class SrbijavozService { 'Accept-Language': 'sr,en;q=0.8', }; - /// Cache for the station lookup — the field re-queries on every keystroke - /// and the station list barely changes. - final Map> _stationCache = - >{}; + /// The whole network, once it has been fetched. + List? _stations; - /// Autocomplete over station names. + /// Autocomplete over station names, matched on device. Future> searchStations(String term) async { final String q = term.trim(); if (q.length < 2) return const []; + return matchStations(await stations(), q); + } + + /// Every station the timetable knows, from memory, storage or the network. + /// + /// 396 rows and 15 KB, so it is worth holding: the picker then answers every + /// keystroke without a request, and works while the connection is flaky. + Future> stations() async { + final List? held = _stations; + if (held != null) return held; + + final ({List stations, bool fresh}) stored = + await _storedStations(); + if (stored.stations.isNotEmpty && stored.fresh) { + return _stations = stored.stations; + } + + try { + final List fetched = await _fetchStations(); + if (fetched.isNotEmpty) { + await _storeStations(fetched); + return _stations = fetched; + } + } catch (e) { + // An expired list still names every station people search for. + if (stored.stations.isEmpty) { + throw TrainServiceException('network error ($e)'); + } + } + + if (stored.stations.isEmpty) { + throw const TrainServiceException('station list unavailable'); + } + return _stations = stored.stations; + } - final String key = q.toLowerCase(); - final List? cached = _stationCache[key]; - if (cached != null) return cached; + /// The stored copy and whether it is still within its TTL. Storage failing — + /// as it does in a plain test binding — only costs us the cache. + Future<({List stations, bool fresh})> _storedStations() async { + try { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final DateTime? fetchedAt = + DateTime.tryParse(prefs.getString(_stationsAtKey) ?? ''); + return ( + stations: _decodeStations( + prefs.getStringList(_stationsKey) ?? const [], + ), + fresh: fetchedAt != null && + DateTime.now().difference(fetchedAt) < _stationsTtl, + ); + } catch (_) { + return (stations: const [], fresh: false); + } + } + + Future _storeStations(List stations) async { + try { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _stationsKey, + stations.map((TrainStation s) => json.encode(s.toJson())).toList(), + ); + await prefs.setString( + _stationsAtKey, + DateTime.now().toIso8601String(), + ); + } catch (_) { + // Nothing to do — the list is held in memory for this run either way. + } + } + /// Asks the lookup with no term, which is what the operator's own front end + /// does with a term it cannot encode, and is answered with every station. + Future> _fetchStations() async { final Uri uri = Uri.parse('$_base/api/stanica/') - .replace(queryParameters: {'term': q}); + .replace(queryParameters: {'term': ''}); final http.Response response = await http.get(uri, headers: { ..._headers, @@ -56,16 +135,25 @@ class SrbijavozService { final List decoded = json.decode(utf8.decode(response.bodyBytes)) as List; - final List stations = decoded + return decoded .cast>() .map(TrainStation.fromJson) .where((TrainStation s) => s.isValid) .toList(); - - _stationCache[key] = stations; - return stations; } + List _decodeStations(List raw) => raw + .map((String s) { + try { + return TrainStation.fromJson(json.decode(s) as Map); + } catch (_) { + return null; + } + }) + .whereType() + .where((TrainStation s) => s.isValid) + .toList(); + /// Direct services between two stations on [date]. Future> connections({ required TrainStation from, @@ -177,20 +265,7 @@ class SrbijavozService { Future> recentStations() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - final List raw = prefs.getStringList(_recentKey) ?? []; - return raw - .map((String s) { - try { - return TrainStation.fromJson( - json.decode(s) as Map, - ); - } catch (_) { - return null; - } - }) - .whereType() - .where((TrainStation s) => s.isValid) - .toList(); + return _decodeStations(prefs.getStringList(_recentKey) ?? []); } Future rememberStation(TrainStation station) async { diff --git a/lib/utils/station_search.dart b/lib/utils/station_search.dart new file mode 100644 index 0000000..f8cc101 --- /dev/null +++ b/lib/utils/station_search.dart @@ -0,0 +1,197 @@ +/// Matching for the railway station picker. +/// +/// The timetable's own lookup only understands Latin. Its front end strips +/// anything else out of the term before sending it, so "Белград" reaches the +/// server as an empty term — and an empty term is answered with all 396 +/// stations. The picker showed that dump as if it were the search result, +/// which is why typing a station in Russian looked like the search was broken. +/// +/// The whole list is 15 KB, so it is fetched once and matched here instead. +/// Russian and Serbian spell the same place differently — Белград/Beograd, +/// Крагуевац/Kragujevac — so on top of transliteration each word of the query +/// is allowed one character of slack against the station name. +library; + +import 'package:srbguide/data/train.dart'; + +/// Cyrillic to Latin, in the convention Serbian itself uses (ж → z, я → ja), +/// extended with the Russian letters Serbian does not have. +const Map _cyrillicToLatin = { + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'ђ': 'dj', + 'е': 'e', + 'ё': 'jo', + 'ж': 'z', + 'з': 'z', + 'и': 'i', + 'й': 'j', + 'ј': 'j', + 'к': 'k', + 'л': 'l', + 'љ': 'lj', + 'м': 'm', + 'н': 'n', + 'њ': 'nj', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'ћ': 'c', + 'у': 'u', + 'ф': 'f', + 'х': 'h', + 'ц': 'c', + 'ч': 'c', + 'џ': 'dz', + 'ш': 's', + 'щ': 'sc', + 'ъ': '', + 'ы': 'i', + 'ь': '', + 'э': 'e', + 'ю': 'ju', + 'я': 'ja', +}; + +/// Serbian Latin diacritics, folded so "nis" finds "NIŠ". +const Map _diacriticsToAscii = { + 'š': 's', + 'č': 'c', + 'ć': 'c', + 'ž': 'z', + 'đ': 'dj', +}; + +/// Lower-cased, transliterated, stripped of everything but letters and digits. +/// +/// `NIŠ` and `Ниш` both come out as `nis`, so the two alphabets meet. +String normalizeStation(String value) { + final StringBuffer out = StringBuffer(); + bool pendingSpace = false; + + for (final int rune in value.toLowerCase().runes) { + final String ch = String.fromCharCode(rune); + final String mapped = _cyrillicToLatin[ch] ?? _diacriticsToAscii[ch] ?? ch; + + final bool plain = RegExp(r'^[a-z0-9]*$').hasMatch(mapped); + if (!plain || mapped.isEmpty) { + // Hyphens, dots and anything else become a single separator: the + // timetable writes both "NOVI SAD" and "PANČEVO-VAROŠ". + if (!plain) pendingSpace = out.isNotEmpty; + continue; + } + if (pendingSpace) { + out.write(' '); + pendingSpace = false; + } + out.write(mapped); + } + return out.toString(); +} + +/// Stations matching [query], best first. +/// +/// Ranked exact, prefix, substring, then one-typo — so "beo" puts BEOGRAD +/// CENTAR above NOVI BEOGRAD, and "Белград" still finds both. +List matchStations( + List stations, + String query, { + int limit = 40, +}) { + final String q = normalizeStation(query); + if (q.isEmpty) return const []; + final List words = q.split(' '); + + final List<_Hit> hits = <_Hit>[]; + + for (final TrainStation station in stations) { + final String name = normalizeStation(station.name); + final int rank; + int at = 0; + if (name == q) { + rank = 0; + } else if (name.startsWith(q)) { + rank = 1; + } else if (name.contains(q)) { + rank = 2; + } else { + final int? typoAt = _typoMatch(name, words); + if (typoAt == null) continue; + rank = 3; + at = typoAt; + } + hits.add(_Hit(rank, at, name.length, station)); + } + + // Rank, then how early in the name the match sits — "Белград" should offer + // BEOGRAD CENTAR before NOVI BEOGRAD — then the shorter name. + hits.sort((_Hit a, _Hit b) { + if (a.rank != b.rank) return a.rank.compareTo(b.rank); + if (a.at != b.at) return a.at.compareTo(b.at); + if (a.length != b.length) return a.length.compareTo(b.length); + return a.station.name.compareTo(b.station.name); + }); + + return hits.take(limit).map((_Hit h) => h.station).toList(); +} + +class _Hit { + final int rank; + final int at; + final int length; + final TrainStation station; + + const _Hit(this.rank, this.at, this.length, this.station); +} + +/// Where the query first lands in [name] when every one of its words matches a +/// word of the name give or take one character, and null when one does not. +/// +/// Short words are left out of the slack: at three letters one edit matches +/// half the network. +int? _typoMatch(String name, List words) { + final List nameWords = name.split(' '); + int? first; + + for (final String word in words) { + final int at = nameWords.indexWhere((String w) => + word.length < 5 ? w.startsWith(word) : _withinOneEdit(w, word)); + if (at == -1) return null; + first ??= at; + } + return first ?? 0; +} + +/// Levenshtein distance of at most one, without building the matrix. +bool _withinOneEdit(String a, String b) { + if ((a.length - b.length).abs() > 1) return false; + + int i = 0; + int j = 0; + bool edited = false; + while (i < a.length && j < b.length) { + if (a[i] == b[j]) { + i++; + j++; + continue; + } + if (edited) return false; + edited = true; + // Substitution when the lengths match, otherwise skip the longer side. + if (a.length == b.length) { + i++; + j++; + } else if (a.length > b.length) { + i++; + } else { + j++; + } + } + // A leftover character on either side is the edit itself. + return !(edited && (i < a.length || j < b.length)); +} diff --git a/test/srbijavoz_live_test.dart b/test/srbijavoz_live_test.dart index c10482c..8e4b29b 100644 --- a/test/srbijavoz_live_test.dart +++ b/test/srbijavoz_live_test.dart @@ -23,6 +23,21 @@ void main() { expect(stations.every((TrainStation s) => s.isValid), isTrue); }, timeout: const Timeout(Duration(seconds: 60))); + test('the whole network is fetched, and searchable in Russian', () async { + final List all = await service.stations(); + // ignore: avoid_print + print('network: ${all.length} stations'); + // The lookup answers a term it cannot spell with everything it has; if that + // ever stops being true, the picker is back to one request per keystroke. + expect(all.length, greaterThan(300)); + + final List found = await service.searchStations('Белград'); + // ignore: avoid_print + print('Белград -> ${found.map((TrainStation s) => s.name).join(', ')}'); + expect(found, isNotEmpty); + expect(found.first.name, contains('BEOGRAD')); + }, timeout: const Timeout(Duration(seconds: 60))); + test('direct connections parse', () async { final List from = await service.searchStations('beograd centar'); diff --git a/test/station_search_test.dart b/test/station_search_test.dart new file mode 100644 index 0000000..dace4a0 --- /dev/null +++ b/test/station_search_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:srbguide/data/train.dart'; +import 'package:srbguide/utils/station_search.dart'; + +/// A slice of the real timetable, spelled the way it spells them. +final List _network = [ + const TrainStation(name: 'BEOGRAD CENTAR', code: '16052'), + const TrainStation(name: 'NOVI BEOGRAD', code: '16003'), + const TrainStation(name: 'NOVI SAD', code: '16808'), + const TrainStation(name: 'NOVI SAD RANŽIRNA', code: '16870'), + const TrainStation(name: 'NIŠ', code: '12551'), + const TrainStation(name: 'NIŠEVAC', code: '14008'), + const TrainStation(name: 'KRAGUJEVAC', code: '13217'), + const TrainStation(name: 'SUBOTICA', code: '23450'), + const TrainStation(name: 'VRŠAC', code: '22101'), + const TrainStation(name: 'UŽICE', code: '15501'), + const TrainStation(name: 'ZRENJANIN', code: '22506'), + const TrainStation(name: 'PANČEVO-VAROŠ', code: '21005'), + const TrainStation(name: 'ŠID', code: '17008'), +]; + +List _names(String query) => + matchStations(_network, query).map((TrainStation s) => s.name).toList(); + +void main() { + group('normalizeStation', () { + test('brings both alphabets to the same spelling', () { + expect(normalizeStation('Ниш'), normalizeStation('NIŠ')); + expect(normalizeStation('NIŠ'), 'nis'); + expect(normalizeStation('Суботица'), 'subotica'); + expect(normalizeStation('Ужице'), normalizeStation('UŽICE')); + }); + + test('treats punctuation as a word break', () { + expect(normalizeStation('PANČEVO-VAROŠ'), 'pancevo varos'); + expect(normalizeStation('BEOGRAD CENTAR'), 'beograd centar'); + expect( + normalizeStation('SUBOTICA JAV.SKLADIŠTA'), 'subotica jav skladista'); + }); + + test('is empty for a query with nothing to match on', () { + expect(normalizeStation(' ... '), ''); + }); + }); + + group('matchStations', () { + test('finds a station typed in Russian', () { + // The bug this exists for: the timetable answered "Белград" with all 396 + // stations, so the picker looked like it had stopped searching. + expect(_names('Белград'), ['BEOGRAD CENTAR', 'NOVI BEOGRAD']); + expect(_names('Ниш'), ['NIŠ', 'NIŠEVAC']); + expect(_names('Нови Сад'), ['NOVI SAD', 'NOVI SAD RANŽIRNA']); + expect(_names('Суботица'), ['SUBOTICA']); + }); + + test('absorbs the spelling differences between the two languages', () { + // Крагуевац/Kragujevac and Белград/Beograd differ by one letter. + expect(_names('Крагуевац'), ['KRAGUJEVAC']); + expect(_names('Зренянин'), ['ZRENJANIN']); + expect(_names('Вршац'), ['VRŠAC']); + expect(_names('Панчево'), ['PANČEVO-VAROŠ']); + }); + + test('still works in Latin, with or without diacritics', () { + expect(_names('nis'), ['NIŠ', 'NIŠEVAC']); + expect(_names('niš'), ['NIŠ', 'NIŠEVAC']); + expect(_names('uzice'), ['UŽICE']); + expect(_names('novi sad'), ['NOVI SAD', 'NOVI SAD RANŽIRNA']); + }); + + test('matches inside the name, which the server could not', () { + // The timetable's lookup is a prefix match: "centar" returned nothing. + expect(_names('centar'), ['BEOGRAD CENTAR']); + expect(_names('ranžirna'), ['NOVI SAD RANŽIRNA']); + }); + + test('puts the closest match first', () { + expect(_names('beograd').first, 'BEOGRAD CENTAR'); + expect(_names('novi sad').first, 'NOVI SAD'); + expect(_names('niš').first, 'NIŠ'); + }); + + test('answers an unknown station with nothing at all', () { + expect(_names('zzzz'), isEmpty); + expect(_names('Мадрид'), isEmpty); + expect(_names('щщщщщ'), isEmpty); + }); + + test('does not let one typo match half the network', () { + // Three letters plus an edit would reach NIŠ, ŠID and more. + expect(_names('nid'), isEmpty); + }); + + test('honours the limit', () { + expect(matchStations(_network, 'n', limit: 2), hasLength(lessThan(3))); + }); + }); +} From 327920d9029fef41484c77140b0ad230569b725b Mon Sep 17 00:00:00 2001 From: ialakey Date: Sun, 6 Sep 2026 16:13:56 +0200 Subject: [PATCH 4/4] docs: changelog, README and Play notes for 2.0.0+14 docs/CHANGELOG.md records what a user of 1.0.0 (versionCode 12, January 2024) will notice, and docs/play/ holds the "What's new" texts ready to paste into the Play Console, both under the 500-character limit. README follows the code: one map instead of two screens, the station lookup matching on device, and the fourth dataset in the weekly sync. --- README.md | 47 ++++++---- docs/CHANGELOG.md | 167 ++++++++++++++++++++++++++++++++++ docs/play/whats-new-en-US.txt | 10 ++ docs/play/whats-new-ru-RU.txt | 10 ++ 4 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 docs/CHANGELOG.md create mode 100644 docs/play/whats-new-en-US.txt create mode 100644 docs/play/whats-new-ru-RU.txt diff --git a/README.md b/README.md index bfa0cc7..c65b025 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,22 @@ Serbian Railways' own site is awkward on a phone. The app talks to the same endp JSON station lookup, plus route search and a per-station departure/arrival board. Stations you have used are remembered, so the usual trip takes two taps. -### Map of relocant-run businesses -362 places — cafés, shops, salons, garages — from the stats.srb.guide catalogue, on an -**OpenStreetMap** map. OSM needs no API key and no billing account, unlike the Google Maps SDK. -The catalogue is bundled, so the list and filters work offline; only the tiles need a connection. -Each place links out to Google Maps for directions. +The lookup only understands Latin — its own front end strips anything else out of the term, and an +empty term is answered with the entire network. So the app asks for that list once (396 stations, +15 KB), caches it for a week, and matches on device: Cyrillic is transliterated, diacritics are +folded, and each word gets one character of slack, so **Белград** finds `BEOGRAD CENTAR` and +`centar` matches inside the name. + +### Map of relocant-run businesses and non-smoking venues +362 places — cafés, shops, salons, garages — from the stats.srb.guide catalogue, plus 145 +non-smoking venues from the *Lokali bez dima* map, on one **OpenStreetMap** map. OSM needs no API +key and no billing account, unlike the Google Maps SDK. Both catalogues are bundled, so the list +and filters work offline; only the tiles need a connection. Each place links out to Google Maps +for directions. + +Filter chips cover the smoking policy (banned outright, or smokeless devices only) alongside the +business categories. A venue that is on both lists is matched by name and proximity and shown as +one pin, not two. ### Visa-free stay calculator Enter your entry date and the app tracks the remaining days of the 29-day visa-free window, shows @@ -102,12 +113,6 @@ Address registration means filling in the same form by hand every time you move. your data once and renders a ready-to-print `.docx` from a bundled template (`assets/template/cardboard.docx`) using `docx_template`, then hands it to the system share sheet. -### Map of useful places -A curated set of Google Maps links — exchange offices, cafés, expat-friendly venues — defined in -`assets/data/locations.json` and shown in an embedded `webview_flutter` view with a dropdown. -Requests that the page hands off to a native app (`intent://`, `geo:`) are opened through the -platform instead of failing inside the web view. - ### Telegram directory 432 relocation chats and channels (`assets/data/tg_chats.json`), synced weekly from the stats.srb.guide catalogue along with their topic, size and whether they are still active, and @@ -135,7 +140,7 @@ Russian, which is the language the guide itself is written in. | Scraping | `http` + `html` — exchange offices, the railway timetable, the sync tools | | Reminders | `flutter_local_notifications`, `timezone`, `flutter_timezone` | | Documents | `docx_template` + `xml`, `path_provider`, `open_file` / `share_plus` to export | -| Integrations | `add_2_calendar`, `url_launcher`, `webview_flutter`, `photo_view` | +| Integrations | `add_2_calendar`, `url_launcher`, `photo_view` | | CI | GitHub Actions — checks, weekly content sync, signed release, daily parser health | --- @@ -165,10 +170,10 @@ lib/ │ ├── deadlines.dart # reminders │ ├── exchange_rate.dart # best rate, NBS reference, converter │ ├── trains.dart # route search + station board -│ ├── places.dart # OpenStreetMap map of the catalogue +│ ├── places.dart # OpenStreetMap map: businesses + non-smoking │ ├── calculator.dart # visa-free day counter + calendar export │ ├── white_cardboard.dart # .docx form generation -│ ├── services.dart, map.dart, tg_chats.dart +│ ├── services.dart, tg_chats.dart │ └── author.dart, settings.dart ├── service/ │ ├── exchange_rate_service.dart # parallel fetch, offline cache, best rate @@ -185,6 +190,7 @@ lib/ tool/ ├── sync_guide.dart # srb.guide -> assets/data/guide.json ├── sync_places.dart # map catalogue -> assets/data/places.json +├── sync_smoking.dart # non-smoking map-> assets/data/smoking.json ├── sync_chats.dart # chat directory -> assets/data/tg_chats.json └── validate_guide.dart # sanity gate before content is committed test/ # unit tests + network-tagged live checks @@ -222,7 +228,16 @@ dart run tool/sync_places.dart # 362 map places dart run tool/sync_chats.dart # 432 Telegram chats ``` -Release builds and signing are documented in [`docs/RELEASE.md`](docs/RELEASE.md). +Build a signed, Play-ready bundle (Windows): + +```powershell +.\tool\create_upload_key.ps1 # once, if there is no upload key yet +.\tool\build_release.ps1 # -> dist/srbguide-.aab and .apk +``` + +Signing, the checks that guard it and the Play upload procedure are in +[`docs/RELEASE.md`](docs/RELEASE.md); what changed in each release is in +[`docs/CHANGELOG.md`](docs/CHANGELOG.md). --- @@ -231,7 +246,7 @@ Release builds and signing are documented in [`docs/RELEASE.md`](docs/RELEASE.md | Workflow | Trigger | What it does | |---|---|---| | `ci.yml` | push / PR | format, analyze, tests, guide validation, debug build | -| `sync-content.yml` | weekly | re-scrapes all three datasets, validates, commits real changes | +| `sync-content.yml` | weekly | re-scrapes all four datasets, validates, commits real changes | | `release.yml` | tag `v*` | signed AAB + APK, verifies the signature, draft release | | `parsers.yml` | daily | runs the parsers against the live sites, opens an issue on failure | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..306c273 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,167 @@ +# Changelog + +Versions are `versionName+versionCode`, both from `version:` in `pubspec.yaml`. +The short "What's new" texts that go into the Play Console live in +[docs/play/](play/). + +## 2.0.0+14 — unreleased + +The first release since January 2024. The app was rebuilt around Android 16, +the guide it ships was re-scraped from srb.guide, and four screens are new. +Everything below is what a user of 1.0.0 (versionCode 12) will notice. + +### The guide + +- **74 articles across 8 sections**, up from 5 articles. Re-synced from + srb.guide with `tool/sync_guide.dart`; each article now carries its source URL + and last-modified date, and credits the original on screen. +- **Articles render correctly.** The old reader split the text on newlines and + built a separate Markdown widget per line, which broke every construct that + spans lines — lists, tables, quotes, fenced code. +- **Search understands Russian inflection.** The query is stemmed, so + "документы" also finds "документов". Adds a section filter, ranking, and a + snippet of the matching text under each hit. +- **The guide updates itself between releases.** Once a day the app asks GitHub + whether the bundled guide changed, conditional on an ETag, so an unchanged + guide costs a single 304. A download is validated before it is accepted and + the bundled copy stays the fallback, so a bad fetch cannot leave the app with + no content. Settings has a manual "check now". +- **Favourites survive guide updates.** They store the article id instead of a + copy of the text, so a saved article follows the guide when it is re-synced. + Existing favourites are migrated by title. + +### New: visa-free calculator (reworked) + +The old screen assumed everyone gets 30 days from a single entry date, and its +remaining-days arithmetic truncated — at 10:00 the day before the deadline it +said zero days left when two were still available. + +- **Three regimes**, as the guide's own calculator article describes them: + 30 days per entry (Russia, Belarus, China, Kazakhstan), 90 days in any 180 + (EU, USA, Ukraine and most others), 30 days in any 365 (Bahamas, Barbados, + Colombia and five more). +- A rolling window cannot be answered from one date, so the screen keeps a + **list of trips**. The single date the old screen stored is carried over on + first run. +- Day counter with a progress ring, the must-leave-by date, and an overstay + warning. Both the entry and the exit day count, as the guide states. Calendar + days are anchored to UTC midnight, so adding days across the March clock + change no longer loses one. +- The deadline can become a reminder or a calendar event. +- The arithmetic lives in `lib/data/visa_rule.dart` under 16 tests. + +### New: deadlines with reminders + +Local notifications for visa runs, residence-permit renewal, paušal tax, eco +tax, insurance and document expiry. Dates are derived from the obligation, and +the recurring ones roll forward on their own. Scheduling is deliberately +inexact — exact alarms need a Play-restricted permission this app does not +qualify for. + +### New: "Мой путь" / My path + +A 24-step relocation checklist across six stages — before the move, first days, +setting up a business, banks, temporary residence, and after — with every step +linked to the guide article that explains it. Progress is stored by article +slug, so it survives a guide re-sync. + +### New: train timetable + +Route search and a per-station departure/arrival board over the Serbian +Railways timetable at w3.srbvoz.rs, with recently used stations remembered. + +**Stations can be searched in Russian.** The operator's lookup only understands +Latin: its own front end strips everything else out of the term, and an empty +term is answered with all 396 stations — so typing "Белград" produced an +alphabetical dump of the whole network and looked like the search had stopped +working. The list is now fetched once, cached for a week and matched on device: +Cyrillic is transliterated, diacritics are folded, each word is allowed one +character of slack (Белград/Beograd, Крагуевац/Kragujevac), and the match runs +inside the name, so "centar" finds BEOGRAD CENTAR — which the server itself +could not do. The picker also stops answering a network failure with "nothing +found". + +### New: one map, replacing the old "Карты" screen + +362 businesses from the stats.srb.guide catalogue plus 145 non-smoking venues +from the *Lokali bez dima* map, together on one OpenStreetMap map. Both +catalogues are bundled, so the list and filters work offline and only the tiles +need a connection. OSM needs no API key and no billing account, unlike the +Google Maps SDK. + +- Filter chips for the smoking policy — smoking banned outright, or smokeless + devices only — next to the business categories, and a badge on the venue. +- A venue on both lists is matched by name and proximity, so it gets one pin + rather than two. +- **The old "Карты" screen is gone.** It was a dropdown of Google My Maps links + opened in a WebView: the Russian-venues map it pointed at is the same + catalogue this screen already shows, the non-smoking map is now bundled as + pins, and the exchange-office search moved into the map's toolbar. The + "black list of apartments" link had been returning a Google 403 for a while + and is dropped. `webview_flutter` goes with it. + +### Exchange rates + +- **Two of the four parsers had been silently returning nothing for months.** + funta.rs renumbered its columns and dropped `tbody.row-hover`; + menjacnicegaga.rs replaced its table with a div ticker. promonet.rs assigned + rows by index parity, so the CHF row was overwriting the RUB quote. All + parsers now match rows by currency code rather than by position. +- **The National Bank of Serbia is a fifth source** and the only one with a real + API. It replaces the previous reference rate, which was scraped out of one + office's ticker. Reference sources are flagged and left out of the "best rate" + comparison — the NBS is not a counter you can walk up to. +- Offices are queried in parallel, each card reports its own failure, the best + rate is highlighted, the last good response is cached for offline use, and + there is a converter. +- A daily CI job runs the parsers against the live sites and opens an issue when + an office changes its markup, so a break is visible before users hit it. + +### Telegram chats + +432 chats from the catalogue, with topic, size and activity, replacing 340 +hand-maintained entries that had drifted. Refreshed weekly along with the guide, +the places catalogue and the non-smoking map. + +### Design + +- Material 3 throughout, from one colour scheme. The seed used to be the Serbian + flag red and Material 3 tints every surface with the seed, so backgrounds and + cards came out pink; the palette is now a blue accent over a neutral surface + ramp. Colour marks actions, the active tab and an urgent deadline, nothing + else. +- A `NavigationBar` shell replaces the navigation drawer and the nested bottom + bars. +- The default language follows the device instead of always starting in English. + +### Under the hood + +- **targetSdk 36 (Android 16)**, which Google Play now requires. AGP 9.1, + Gradle 9.3.1, Kotlin 2.4, Java 17, the Kotlin DSL. `minSdk` moves 21 → 24 + (Flutter's floor), so Android 5.x devices no longer receive updates. +- Release signing is conditional on `android/key.properties`; the previous + config threw at configuration time whenever that file was absent. +- `versionCode` and `versionName` now both come from `pubspec.yaml`. +- Search no longer re-lowercases every article on every keystroke (1.5 MB per + search); the lowercased forms are computed once per article. +- The guide is parsed once and cached rather than re-read per screen. +- Seven `use_build_context_synchronously` violations fixed; the analyzer is + clean and CI keeps it that way. +- 5.6 MB of orphaned images dropped from the repository. +- CI on every push: formatting, analysis, tests, guide validation, debug APK. + A signed-release workflow that refuses to publish anything not signed with a + SHA-256 upload key. MIT licence, and a NOTICE recording that it does not cover + the guide content, which belongs to the authors of srb.guide and is used with + their permission. + +### Known limitations + +- Article images are fetched from srb.guide, so article text is available + offline but its images are not. +- iOS is unmaintained: the `ios/` project has no Podfile and still declares an + iOS 11 deployment target, below what the current plugins require. + +## 1.0.0+12 — 2024-01-21 + +Guide, visa-run calculator, white-card (beli karton) form, exchange rates, +Telegram chat directory, maps, favourites, RU/EN localisation. diff --git a/docs/play/whats-new-en-US.txt b/docs/play/whats-new-en-US.txt new file mode 100644 index 0000000..b94e7d1 --- /dev/null +++ b/docs/play/whats-new-en-US.txt @@ -0,0 +1,10 @@ +The first update since 2024. + +• Guide: 74 articles in 8 sections, up from 5, with better search +• Visa-free calculator: 3 regimes (30 per entry, 90/180, 30/365), trip list +• Deadlines with reminders: visa run, residence permit, tax, insurance +• My path — a 24-step relocation checklist +• Serbian Railways timetable, searchable in Russian +• One map: 362 relocant businesses and 145 non-smoking venues +• Exchange rates: 5 sources including the National Bank +• Redesigned for Android 16 diff --git a/docs/play/whats-new-ru-RU.txt b/docs/play/whats-new-ru-RU.txt new file mode 100644 index 0000000..c2853e3 --- /dev/null +++ b/docs/play/whats-new-ru-RU.txt @@ -0,0 +1,10 @@ +Большое обновление, первое с 2024 года. + +• Гайд: 74 статьи в 8 разделах вместо 5, поиск с учётом падежей +• Калькулятор визарана: 3 режима (30 дней, 90/180, 30/365), список поездок +• Дедлайны: визаран, ВНЖ, паушал, эко-налог, страховка +• «Мой путь» — чек-лист переезда из 24 шагов +• Расписание поездов Србвоз: станции ищутся и по-русски +• Одна карта: 362 бизнеса релокантов и 145 заведений без курения +• Курсы валют: 5 источников, включая НБС +• Новый дизайн и Android 16