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.

Google Play - Flutter - Platforms + Flutter 3.47 + Android 16 + MIT

-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. -

- Guide - Visa-free calculator - White cardboard -

-

- Exchange rates - Map -

+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.