diff --git a/README.md b/README.md
index d99bd20..466c425 100644
--- a/README.md
+++ b/README.md
@@ -1,94 +1,84 @@
# Serbia Guide
**A Flutter app for people relocating to Serbia.** It answers the questions every newcomer hits in
-their first months: how many visa-free days are left, how to fill in a *beli karton*, where today's
-best exchange rate is, and which Telegram chat to ask.
+their first months: what to do next, how many visa-free days are left, when the next tax deadline
+is, how to fill in a *beli karton*, and where today's best exchange rate is.
-
-
+
+
+
-Part of a three-repository system:
+---
-| Repository | Role |
-|---|---|
-| **srbguide** (this one) | Flutter mobile client |
-| [serbiaguide](https://github.com/ialakey/serbiaguide) | Spring Boot REST API — locations, guides, Telegram chat directory |
-| [serbiaguide-front-admin](https://github.com/ialakey/serbiaguide-front-admin) | React admin panel for editing the content |
+## Features
----
+### Guide
+74 articles across 8 sections, mirrored from [srb.guide](https://www.srb.guide/) with the authors'
+permission and bundled as Markdown in `assets/data/guide.json`. Every article keeps its source URL
+and last-modified date, and links back to the original.
-## Screenshots
+**Article text works with no connection**, which matters on day one in a new country. Images are
+loaded from srb.guide when the device is online and fall back to a placeholder when it is not.
-
-
-
-
-
-
-
-
-
+Search covers titles, summaries and body text. The query is stemmed, so the Russian "банка" also
+finds "банки" and "банковский" — a full morphological analyser would be overkill over 1.5 MB of
+text, so only the query is stemmed and matched as a prefix. Results are ranked title-first, filter
+by section, and show the snippet that matched.
----
+### "My path" checklist
+The guide explains *how* to do each thing; the checklist says *what to do next*. 24 steps across
+six stages, each linked to the article that explains it. Steps bind to articles by slug rather than
+title, so they survive a content re-sync.
-## Features
+### Deadline reminders
+Tracks visa runs, residence-permit renewal, paušal tax (the 15th of each month), eco tax
+(30 April), health insurance and document expiry, and notifies ahead of each one. Dates are derived
+from what the obligation is, so most reminders need no data entry, and the monthly and yearly ones
+roll forward on their own.
+
+Scheduling is deliberately **inexact**: exact alarms require `SCHEDULE_EXACT_ALARM` /
+`USE_EXACT_ALARM`, which Google Play grants only to alarm and calendar apps. Reminders survive a
+reboot via the plugin's boot receiver.
+
+### Live exchange rates
+Serbian exchange offices don't publish an API, so the app scrapes their public pages. Four parsers
+live in `lib/service/parser/` — one per office — each matching rows **by currency code** rather
+than by row and column position, because these sites renumber their tables and positional parsing
+fails silently.
+
+Offices are queried in parallel and each card reports its own failure with a retry, so one dead
+site doesn't empty the screen. The best rate across offices is highlighted, the National Bank of
+Serbia reference rate is shown as a yardstick, the last successful fetch is cached for offline use,
+and a converter uses the best rate.
### Visa-free stay calculator
Enter your entry date and the app tracks the remaining days of the 29-day visa-free window, shows
-the exit deadline, and pushes the date straight into the system calendar via `add_2_calendar`, so
-the reminder survives even if the app is uninstalled.
+the exit deadline, and can push the date into the system calendar via `add_2_calendar`.
### White cardboard (*beli karton*) generator
-Address registration in Serbia means filling in the same form by hand every time you move. The app
-stores your data once and renders a ready-to-print `.docx` from a bundled template
+Address registration means filling in the same form by hand every time you move. The app stores
+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.
-### Deadline reminders
-Missing a Serbian deadline costs money. The app tracks visa runs, residence-permit renewal,
-paušal tax (the 15th of every month), eco tax (30 April), insurance and document expiry, and
-notifies ahead of each one. Dates are derived from what the obligation *is*, so most reminders
-need no data entry; monthly and yearly ones roll forward on their own. Scheduling is deliberately
-inexact — exact alarms are a Play-restricted permission that a reminder app does not need.
-
-### "My path" checklist
-The guide explains *how* to do each thing; the checklist says *what to do next*. 24 steps across
-six stages, each linked to the article that explains it, with progress on the home screen.
-
-### Live exchange rates
-Serbian exchange offices don't publish an API, so the app scrapes their public pages directly.
-Four independent parsers live in `lib/service/parser/` — one per office — each matching rows by
-**currency code** rather than row position, because these sites renumber their tables regularly
-and positional parsing fails silently. The best rate across offices is highlighted, the National
-Bank reference rate is shown as a yardstick, and the last successful fetch is cached so the screen
-still works offline.
-
-### Offline guide
-The relocation guide — 74 articles in 8 sections — ships as Markdown inside the app
-(`assets/data/guide.json`), mirrored from [srb.guide](https://www.srb.guide/) with the authors'
-permission and re-scraped weekly by CI. Rendered with full-text search, favourites and an
-adjustable font size. **It works with no connection** — which matters on day one in a new country,
-before you have a local SIM.
-
-Search stems the query, so the Russian "банка" finds "банки" and "банковский"; results are ranked
-title-first and show the snippet that matched.
-
### Map of useful places
-A curated set of Google Maps searches — exchange offices, non-smoking cafés, expat-friendly venues —
-defined in `assets/data/locations.json` and rendered in an embedded `webview_flutter` view with a
-dropdown to switch between them.
+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
-A curated list of relocation chats and channels (`assets/data/tg_chats.json`), opened directly in
-the Telegram app through `url_launcher`.
+A curated list of relocation chats and channels (`assets/data/tg_chats.json`), opened in the
+Telegram app through `url_launcher`.
### Personalisation
-Light/dark theme, RU/EN localisation, adjustable text size, and a configurable start screen — all
-persisted with `shared_preferences` and restored on launch.
+Light/dark theme, RU/EN interface, a configurable start tab, and per-article text size — persisted
+with `shared_preferences`. The interface language defaults to the device language, falling back to
+Russian, which is the language the guide itself is written in.
---
@@ -96,17 +86,18 @@ persisted with `shared_preferences` and restored on launch.
| Area | Choice |
|---|---|
-| Framework | Flutter, Dart |
-| State | `provider` (`LanguageProvider`) + `setState` for local screen state |
-| Persistence | `shared_preferences` (theme, language, start screen, saved form data) |
-| Localisation | `flutter_localizations` + ARB files (`lib/l10n/app_en.arb`, `app_ru.arb`) |
+| Framework | Flutter 3.47, Dart 3 |
+| Android | AGP 9.1, Gradle 9.3.1, Kotlin 2.4, Java 17, `compileSdk`/`targetSdk` 36, `minSdk` 24 |
| UI | Material 3, one seeded `ColorScheme` for light and dark |
+| State | `provider` for the locale, `setState` for local screen state |
+| Persistence | `shared_preferences` (theme, language, start tab, favourites, deadlines, checklist) |
+| Localisation | `flutter_localizations` + ARB files (`lib/l10n/app_en.arb`, `app_ru.arb`), `intl` |
| Content | Offline JSON + Markdown in `assets/data/`, rendered with `flutter_markdown_plus` |
| Scraping | `http` + `html` — one parser class per exchange office |
-| Reminders | `flutter_local_notifications` + `timezone` |
-| Documents | `docx_template` + `xml` for `.docx` generation, `open_file` / `share_plus` to export |
+| 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` |
-| CI | GitHub Actions — analyze/test, weekly content sync, signed release, daily parser health |
+| CI | GitHub Actions — checks, weekly content sync, signed release, daily parser health |
---
@@ -114,38 +105,42 @@ persisted with `shared_preferences` and restored on launch.
```
lib/
-├── main.dart # bootstrap: prefs, locale, theme, warm caches
-├── data/ # models + repositories
-│ ├── guide_dto.dart # guide content model
-│ ├── guide_repository.dart # single cached load, search, favourites
-│ ├── deadline.dart # deadline kinds and their default schedules
-│ ├── journey.dart # the relocation checklist
+├── main.dart # bootstrap: prefs, locale, theme, warm caches
+├── data/
+│ ├── guide_dto.dart # guide content model
+│ ├── guide_repository.dart # single cached load, search, favourites, history
+│ ├── deadline.dart # deadline kinds and their default schedules
+│ ├── deadline_repository.dart
+│ ├── journey.dart # the relocation checklist
│ └── currency_rate.dart
├── screens/
-│ ├── app_shell.dart # NavigationBar: Home / Guide / Services / Saved
-│ ├── home.dart # search, rate, next deadline, checklist progress
-│ ├── guide.dart # collapsible sections
-│ ├── article.dart # reader: text size, bookmark, source link
-│ ├── guide_search.dart # stemmed full-text search
-│ ├── deadlines.dart # reminders
-│ ├── journey.dart # "my path" checklist
-│ ├── exchange_rate.dart # best rate, NBS reference, converter
-│ ├── calculator.dart # visa-free day counter + calendar export
-│ ├── white_cardboard.dart # .docx form generation
-│ ├── services.dart, map.dart, tg_chats.dart, favourites.dart
-│ ├── author.dart, settings.dart
+│ ├── app_shell.dart # NavigationBar: Home / Guide / Services / Saved
+│ ├── home.dart # search, rate, next deadline, checklist progress
+│ ├── guide.dart # collapsible sections
+│ ├── article.dart # reader: text size, bookmark, source link
+│ ├── guide_search.dart # stemmed full-text search
+│ ├── favourites.dart
+│ ├── journey.dart # "my path" checklist
+│ ├── deadlines.dart # reminders
+│ ├── exchange_rate.dart # best rate, NBS reference, converter
+│ ├── calculator.dart # visa-free day counter + calendar export
+│ ├── white_cardboard.dart # .docx form generation
+│ ├── services.dart, map.dart, tg_chats.dart
+│ └── author.dart, settings.dart
├── service/
-│ ├── exchange_rate_service.dart # parallel fetch, offline cache, best rate
-│ ├── notification_service.dart # scheduling
-│ ├── document_generate.dart # .docx rendering from template
-│ └── parser/ # one scraper per exchange office
-├── theme/app_theme.dart # Material 3 light + dark
-├── utils/search_stem.dart # Russian query stemming
-├── widget/ # reusable UI
-├── localization/, l10n/ # AppLocalizations + ARB files
+│ ├── exchange_rate_service.dart # parallel fetch, offline cache, best rate
+│ ├── notification_service.dart # reminder scheduling
+│ ├── document_generate.dart # .docx rendering from template
+│ ├── url_launcher_helper.dart
+│ └── parser/ # one scraper per exchange office
+├── theme/app_theme.dart # Material 3 light + dark from one seed
+├── utils/ # search stemming, section icons
+├── widget/ # markdown renderer, tiles, form fields
+├── dialogs/, localization/, l10n/, provider/
tool/
-├── sync_guide.dart # re-scrape srb.guide -> assets/data/guide.json
-└── validate_guide.dart # sanity gate before that content is committed
+├── sync_guide.dart # re-scrape srb.guide -> assets/data/guide.json
+└── validate_guide.dart # sanity gate before that content is committed
+test/ # unit tests + a network-tagged parser check
```
---
@@ -159,15 +154,19 @@ flutter pub get
flutter run
```
-Build a release APK:
+No API keys or backend are required — the app ships its content offline and reaches the network
+only for exchange rates and guide images.
+
+Checks, as CI runs them:
```bash
-flutter build apk --release
+dart format --output=none --set-exit-if-changed lib tool test
+flutter analyze
+flutter test --exclude-tags live # unit tests
+flutter test test/exchange_parsers_live_test.dart # hits the real exchange sites
+dart run tool/validate_guide.dart
```
-No API keys or backend are required — the app ships its content offline and only reaches the
-network for live exchange rates.
-
Refresh the bundled guide from the website:
```bash
@@ -183,13 +182,29 @@ 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-guide.yml` | weekly | re-scrapes srb.guide, validates, commits only real changes |
+| `sync-guide.yml` | weekly | re-scrapes srb.guide, validates, commits only real content 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 |
-The daily parser check is the important one. The exchange offices redesign without notice, and two
-of the four parsers had been returning nothing for months before anyone noticed — the whole point
-of that job is that CI finds out before users do.
+`sync-guide.yml` commits third-party content unattended, so `tool/validate_guide.dart` gates it:
+section and article counts, per-article length, duplicate source URLs, and a rejection if the
+bundle shrank by more than 25% against the previous one.
+
+`release.yml` refuses to publish anything questionable — it checks that the upload key is
+`SHA256withRSA`, that the APK carries APK Signature Scheme v2 with a SHA-256 certificate digest,
+that it is not the Android debug certificate, and that the merged manifest still targets SDK 36.
+
+`parsers.yml` exists because scraper breakage is silent: an office redesigns its page, the parser
+returns nothing, and the screen simply looks empty. Running the live tests on a schedule means CI
+notices before users do.
+
+---
+
+## Platforms
+
+Android is the maintained and released target. An `ios/` project is present in the repository but
+is not currently built or verified — it has no `Podfile` and still declares an iOS 11 deployment
+target, below what the current plugin set requires.
---
@@ -197,7 +212,7 @@ of that job is that CI finds out before users do.
The exchange-rate parsers depend on the HTML of third-party sites and will break when those sites
are redesigned. Each parser is isolated so a broken office degrades that one card rather than the
-screen, and each card reports its own failure with a retry.
+screen.
Guide content belongs to the authors of srb.guide and is used with their permission; see
[`NOTICE`](NOTICE). The MIT licence in [`LICENSE`](LICENSE) covers the source code only.