From cd58101f91c7fb6d72ef35ef744090c6968f8358 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 23 Aug 2026 16:57:23 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a42e8c6..8961e01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,17 @@ CI. Выпускать нечего (правка тестов, чистый рефакторинг, работа только с документацией) — метка «без-релиза» на PR, тогда ни релиза, ни проверки. +## Тронул код — актуализируй документацию проекта + +Правки, которые меняют поведение, архитектуру или процесс, описанные в +`README.md`, `docs/*.md`, `CONTRIBUTING.md` или другой документации +проекта, — идут вместе с правкой этой документации, **тем же изменением**, +а не потом. Это отдельно от CHANGELOG (для него своё правило выше): здесь +речь про то, что документация продолжает описывать код таким, какой он есть. + +Почему: документация, разошедшаяся с кодом, вреднее её отсутствия — по ней +ориентируются и человек, и агент в следующей сессии. + ## Без Gradle-сборки Никогда не запускать `./gradlew` или любые Gradle-команды (`assembleDebug`, `build`, `test` и т.п.). From 2bb94c0f5857d579c763a7ee941459c15b6a543e Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 23 Aug 2026 16:57:27 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B2=20TODO=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D1=83=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=20=D1=82=D0=B0=D0=BF=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/TODO.md b/TODO.md index dd1ada4..1183ff7 100644 --- a/TODO.md +++ b/TODO.md @@ -98,6 +98,18 @@ где в названии есть слово «интенсив». Правильный признак — `activity_id` у записи, он уже приходит в `RecordData`. Решено делать, но не срочно. +### Тап по уведомлению об обновлении + +Уведомление «доступно обновление» открывает «О программе» +(`EXTRA_OPEN_ABOUT` → `MainActivity`), но экран приходит в исходном виде: +найденную версию приходится искать заново кнопкой «Проверить обновления». +Уведомление уже знает, какая версия вышла, — нужен нормальный механизм: тап +ведёт туда, где обновление живёт в приложении, и там сразу готовое +предложение обновиться, без второго нажатия. + +- [ ] Пробросить версию из уведомления на экран и показать её без повторной + проверки + ### Тесты обновления Крайние случаи проверяются кодом, а не руками на телефоне — гоняются в CI на From a584c37cf23b4977b62661c048a7ea2a5b6ce236 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 23 Aug 2026 17:21:09 +0300 Subject: [PATCH 3/5] docs: switch commit message rule to Conventional Commits --- .cursor/rules/git-commits-ru.mdc | 30 ------------------------------ .cursor/rules/git-commits.mdc | 29 +++++++++++++++++++++++++++++ .cursorrules | 22 +++++++++++----------- .github/pull_request_template.md | 4 ++-- CLAUDE.md | 18 ------------------ CONTRIBUTING.md | 23 +++++++++++++---------- docs/profile-drawer.md | 2 +- docs/updater/TASKS.md | 6 +++--- 8 files changed, 59 insertions(+), 75 deletions(-) delete mode 100644 .cursor/rules/git-commits-ru.mdc create mode 100644 .cursor/rules/git-commits.mdc diff --git a/.cursor/rules/git-commits-ru.mdc b/.cursor/rules/git-commits-ru.mdc deleted file mode 100644 index 3438938..0000000 --- a/.cursor/rules/git-commits-ru.mdc +++ /dev/null @@ -1,30 +0,0 @@ ---- -description: Короткие git-коммиты на русском (агент и ✨ — см. также .cursorrules) -alwaysApply: true ---- - -# Git: короткие коммиты на русском - -**Кнопка ✨ в Source Control** читает правила из **`.cursorrules` в корне репозитория**, а не из этого файла. Если ✨ игнорирует формат — правь `.cursorrules`. - -**Агент в чате** — следуй и этому файлу, и `.cursorrules`. - -## Формат - -- **Одна строка**, до **72 символов**, без тела коммита и переносов. -- Одна суть: не перечисляй все файлы и подзадачи. -- Глагол в прошедшем времени: «Добавил», «Исправил», «Обновил», «Убрал». -- Без `feat:`, `fix:`, `chore:`. -- Имена в коде — латиницей можно; описание — только русский. - -## Примеры - -- `Добавил API ежемесячных выплат Pay` -- `Исправил жесты drawer: панель следует за пальцем` -- `Обновил PayMonthlyChart` - -## Нельзя - -- Длинные «отчёты» на полэкрана с перечислением моделей, маршрутов и стилей. -- `Refactor CalendarHeader for improved usability` -- `fix: drawer gestures` diff --git a/.cursor/rules/git-commits.mdc b/.cursor/rules/git-commits.mdc new file mode 100644 index 0000000..e6d9ddd --- /dev/null +++ b/.cursor/rules/git-commits.mdc @@ -0,0 +1,29 @@ +--- +description: Conventional Commits (агент и ✨ — см. также .cursorrules) +alwaysApply: true +--- + +# Git: Conventional Commits + +**Кнопка ✨ в Source Control** читает правила из **`.cursorrules` в корне репозитория**, а не из этого файла. Если ✨ игнорирует формат — правь `.cursorrules`. + +**Агент в чате** — следуй и этому файлу, и `.cursorrules`. + +## Формат + +- `<тип>[область]: <описание>`, одна строка, до 72 символов, без тела коммита. +- Тип: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. +- Описание на английском, повелительное наклонение, без точки в конце. +- Одна суть: не перечисляй все файлы и подзадачи. + +## Примеры + +- `feat(pay): add monthly payout API` +- `fix(drawer): panel follows finger on left-edge drag` +- `refactor: simplify PayMonthlyChart` + +## Нельзя + +- Длинные «отчёты» на полэкрана с перечислением моделей, маршрутов и стилей. +- `Обновил PayMonthlyChart` — старый формат, без типа +- `drawer gestures` — нет типа и глагола-действия diff --git a/.cursorrules b/.cursorrules index 6c86d86..fc129fa 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,20 +1,20 @@ # Сообщения git-коммита (кнопка ✨ в Source Control) -При генерации сообщения коммита (`cursor.generateGitCommitMessage`) — только это: +При генерации сообщения коммита (`cursor.generateGitCommitMessage`) — формат +Conventional Commits: -- Язык: русский, без английского в теме. -- Длина: одна строка, не длиннее 72 символов (без переносов и второго абзаца). -- Содержание: только одна главная мысль; максимально кратко. Не перечисляй файлы, классы, параметры или детали реализации. -- Время: прошедшее — «Добавил», «Исправил», «Обновил», «Убрал», «Перенес». -- Без префиксов feat:, fix:, chore:. +- `<тип>[область]: <описание>`, одна строка, до 72 символов, без тела коммита. +- Тип: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. +- Описание на английском, повелительное наклонение, без точки в конце. +- Только одна главная мысль; не перечисляй файлы, классы, параметры или детали реализации. Примеры (копируй длину и тон): -- Добавил API ежемесячных выплат Pay -- Исправил жесты drawer с левого края -- Обновил PayMonthlyChart +- feat(pay): add monthly payout API +- fix(drawer): handle left-edge gesture correctly +- refactor: simplify PayMonthlyChart Запрещено: -- «Добавлены новые модели и маршруты… Обновлены стили…» -- любое сообщение длиннее одной короткой фразы. +- «Added new models and routes… Updated styles…» — несколько мыслей сразу +- любое сообщение длиннее одной короткой строки. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65df1e8..506ff30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,8 +11,8 @@ ## Чек-лист -- [ ] Коммиты по [правилам проекта](../blob/main/CONTRIBUTING.md#правила-коммитов): - одна строка, до 72 символов, по-русски, глагол в прошедшем времени +- [ ] Коммиты по [Conventional Commits](https://www.conventionalcommits.org/) — + см. [правила проекта](../blob/main/CONTRIBUTING.md#правила-коммитов) - [ ] `./gradlew :app:testDebugUnitTest` зелёный - [ ] Заметки к релизу лежат в разделе «Не выпущено» [CHANGELOG.md](../blob/main/CHANGELOG.md) — их пишет агент по команде diff --git a/CLAUDE.md b/CLAUDE.md index 8961e01..e93a874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,5 @@ # Правила для агента -## Git-коммиты — только на русском, кратко - -- Одна строка, до 72 символов, без тела коммита и переносов. -- Одна главная мысль — не перечислять файлы, классы, параметры, детали реализации. -- Глагол в прошедшем времени: «Добавил», «Исправил», «Обновил», «Убрал», «Перенес». -- Без префиксов `feat:`, `fix:`, `chore:`. -- Имена в коде можно латиницей, описание — только русский. - -Примеры: -- Добавил API ежемесячных выплат Pay -- Исправил жесты drawer с левого края -- Обновил PayMonthlyChart - -Нельзя: -- «Добавлены новые модели и маршруты… Обновлены стили…» -- `Refactor CalendarHeader for improved usability` -- любое сообщение длиннее одной короткой фразы - ## Тронул код — дописал CHANGELOG Любая правка, которую видно пользователю, идёт вместе с записью в разделе diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75f0b4f..bf61333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,22 +32,25 @@ Neiro — личный инструмент одного педагога, а н ## Правила коммитов -Одна строка, до 72 символов, по-русски, глагол в прошедшем времени. Без тела -коммита, без префиксов `feat:` / `fix:`, без перечисления файлов и классов — -одна главная мысль. Имена в коде можно латиницей, описание — только русский. -Те же правила действуют для агентов, они записаны в [CLAUDE.md](CLAUDE.md). +Формат — [Conventional Commits](https://www.conventionalcommits.org/): +`<тип>[область]: <описание>`. Одна строка, до 72 символов, без тела коммита. + +Тип: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, +`ci`, `chore`, `revert`. Описание — на английском, повелительное наклонение, +без точки в конце, одна главная мысль на коммит. Те же правила действуют для +агентов — скилл `git-commit`. Так: -- `Добавил API ежемесячных выплат Pay` -- `Исправил жесты drawer с левого края` -- `Обновил PayMonthlyChart` +- `feat(pay): add monthly payout API` +- `fix(drawer): handle left-edge gesture correctly` +- `refactor: simplify PayMonthlyChart` Не так: -- `Добавлены новые модели и маршруты… Обновлены стили…` — несколько мыслей -- `Refactor CalendarHeader for improved usability` — английский и префикс -- `Правки` — не сказано, что изменилось +- `Added new models and routes… Updated styles…` — несколько мыслей +- `Обновил PayMonthlyChart` — старый формат, без типа +- `fix bug` — не сказано, что именно исправлено ## Ветки и PR diff --git a/docs/profile-drawer.md b/docs/profile-drawer.md index 257f58f..2332b97 100644 --- a/docs/profile-drawer.md +++ b/docs/profile-drawer.md @@ -47,4 +47,4 @@ ## Коммиты -Сообщения коммитов в репозитории — **на русском**. См. `.cursorrules` и `.cursor/rules/git-commits-ru.mdc`. +Сообщения коммитов в репозитории — Conventional Commits. См. `.cursorrules` и `.cursor/rules/git-commits.mdc`. diff --git a/docs/updater/TASKS.md b/docs/updater/TASKS.md index 560c007..355d2a5 100644 --- a/docs/updater/TASKS.md +++ b/docs/updater/TASKS.md @@ -226,9 +226,9 @@ PR #33: вышла `0.1.6`, между слиянием и релизом ни - **Как собрать** — ссылка на [быстрый старт](../../README.md#быстрый-старт-разработка); честно сказать, что без `local.properties` вход не пройдёт, а без `google-services.json` не будет push — собрать при этом можно - - **Правила коммитов** — одна строка до 72 символов, по-русски, глагол в - прошедшем времени, без префиксов `feat:`/`fix:`, не перечислять файлы; - три примера «так» и три «не так» (взять из [CLAUDE.md](../../CLAUDE.md)) + - **Правила коммитов** — Conventional Commits (`<тип>[область]: <описание>`), + английский, повелительное наклонение, до 72 символов, не перечислять файлы; + три примера «так» и три «не так» (см. [CONTRIBUTING.md](../../CONTRIBUTING.md#правила-коммитов)) - **Ветки и PR** — ветка от `main`, один PR — одна мысль, ссылка на шаблон - **Тесты** — `./gradlew :app:testDebugUnitTest` до отправки - **Что обсуждается до кода** — новые зависимости, правки From 96d3dac49de4e0fda8b1b29dc8c4b22cf1c418ed Mon Sep 17 00:00:00 2001 From: Greem4 Date: Tue, 1 Sep 2026 14:01:58 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(update):=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=8E=20=C2=AB=D0=9E=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B5=C2=BB=20=D1=81=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D0=BC=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тап по уведомлению «доступно обновление» приводил на экран в исходном виде: найденную версию приходилось искать заново кнопкой «Проверить обновления», хотя телефон про неё уже знал. Теперь результат проверки сохраняется целиком (`available_update`, `UpdateOffer`) и поднимается при создании `UpdateViewModel` — карточка «Доступна 0.2.4» с кнопками «Обновить» и «Пропустить» стоит на экране сразу, из уведомления, из настроек и без сети. Прокидывание версии через `Intent` починило бы только путь из уведомления. Запись стирается там, где перестаёт быть правдой: проверка вернула «новее нечего», нажали «Пропустить», обновление установилось. Точка «доступна версия» в настройках читает тот же источник, а не отдельный `last_known_version_code`, — двух ответов на один вопрос больше нет. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M1hENvmR48YvNMmoc1Pzvz --- CHANGELOG.md | 13 ++- TODO.md | 12 --- .../neiro/update/UpdateCheckCoordinator.kt | 14 ++- .../greemlab/neiro/update/UpdateNotifier.kt | 13 +-- .../ru/greemlab/neiro/update/UpdateOffer.kt | 102 ++++++++++++++++++ .../neiro/update/UpdatePreferences.kt | 43 ++++++++ .../greemlab/neiro/update/UpdateViewModel.kt | 43 ++++++-- app/src/main/res/values/strings.xml | 2 +- .../greemlab/neiro/update/UpdateOfferTest.kt | 77 +++++++++++++ docs/updater/ARCHITECTURE.md | 28 ++++- docs/updater/TASKS.md | 3 + 11 files changed, 315 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/ru/greemlab/neiro/update/UpdateOffer.kt create mode 100644 app/src/test/java/ru/greemlab/neiro/update/UpdateOfferTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc94a9..f4c0453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ ## [Не выпущено] -Пока пусто. Сюда дописывается изменение, заметное пользователю, — тем же PR, -которым оно вносится, а не по памяти в день выпуска. Заголовки внутри: -«Добавлено», «Изменено», «Исправлено», «Убрано». +### Изменено + +- **Уведомление об обновлении ведёт прямо к кнопке** — нажатие на «доступно + обновление» открывает «О программе», где новая версия уже найдена: видно, что + изменилось, и стоят кнопки «Обновить» и «Пропустить». Раньше на этом экране + приходилось заново нажимать «Проверить обновления», хотя телефон про версию + уже знал. +- **Найденное обновление не теряется** — если приложение закрыть, предложение + обновиться остаётся на экране «О программе» и показывается даже без сети, + пока версию не поставят или не пропустят. ## [0.2.3] — 2026-08-21 diff --git a/TODO.md b/TODO.md index 1183ff7..dd1ada4 100644 --- a/TODO.md +++ b/TODO.md @@ -98,18 +98,6 @@ где в названии есть слово «интенсив». Правильный признак — `activity_id` у записи, он уже приходит в `RecordData`. Решено делать, но не срочно. -### Тап по уведомлению об обновлении - -Уведомление «доступно обновление» открывает «О программе» -(`EXTRA_OPEN_ABOUT` → `MainActivity`), но экран приходит в исходном виде: -найденную версию приходится искать заново кнопкой «Проверить обновления». -Уведомление уже знает, какая версия вышла, — нужен нормальный механизм: тап -ведёт туда, где обновление живёт в приложении, и там сразу готовое -предложение обновиться, без второго нажатия. - -- [ ] Пробросить версию из уведомления на экран и показать её без повторной - проверки - ### Тесты обновления Крайние случаи проверяются кодом, а не руками на телефоне — гоняются в CI на diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt index 869388b..b24a3ae 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt @@ -114,15 +114,27 @@ object UpdateCheckCoordinator { suspend fun checkNow(context: Context, force: Boolean = false): UpdateStatus { val appContext = context.applicationContext val status = UpdateChecker.create(appContext).check(force) + val preferences = UpdatePreferences.get(appContext) when (status) { is UpdateStatus.Available -> { Log.i(TAG, "Есть обновление: ${status.info.version.versionName}") + // Найденное предложение переживает закрытие приложения: экран + // «О программе» поднимает его при открытии, и тап по + // уведомлению приводит сразу к кнопке «Обновить», а не к + // пустому экрану с повторной проверкой. + preferences.availableUpdate = status.info // Правило «об одной версии говорим один раз» и проверка // «пропустили» живут внутри UpdateNotifier — здесь не дублируем. UpdateNotifier.notifyIfNeeded(appContext, status.info) } - is UpdateStatus.UpToDate -> Log.i(TAG, "Обновлений нет") + is UpdateStatus.UpToDate -> { + // Новее нечего — значит и предлагать нечего: релиз откатили или + // обновились мимо приложения. Оставленная запись предлагала бы + // скачать APK, которого в релизе уже нет. + preferences.availableUpdate = null + Log.i(TAG, "Обновлений нет") + } is UpdateStatus.Throttled -> Log.i(TAG, "Проверяли меньше суток назад, GitHub не трогаем") is UpdateStatus.Blocked -> Log.i(TAG, "Самообновление выключено: ${status.why}") is UpdateStatus.Failed -> Log.w(TAG, "Проверка не удалась: ${status.failure}") diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt index 929b134..b2eb8b4 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import ru.greemlab.neiro.BuildConfig import ru.greemlab.neiro.MainActivity import ru.greemlab.neiro.R import ru.greemlab.neiro.notifications.NeiroNotificationBranding @@ -92,19 +91,17 @@ object UpdateNotifier { /** * Известна ли версия новее установленной — для точки на пункте «О программе». - * Берётся из кэша прошлой проверки, в сеть не ходит. + * Берётся из сохранённого предложения, в сеть не ходит. + * + * Источник тот же, что у экрана: точка обещает готовую кнопку «Обновить», и + * если предложения нет (релиз без APK, откат), обещать нечего. * * @return версия для подписи или null, если показывать нечего. */ fun knownNewerVersion(context: Context): ReleaseVersion? { val appContext = context.applicationContext if (!UpdateChannelGate.isAllowed(appContext)) return null - - val preferences = UpdatePreferences.get(appContext) - val known = preferences.lastKnownVersionCode - if (known <= BuildConfig.VERSION_CODE) return null - if (known <= preferences.skippedVersionCode) return null - return ReleaseVersion.fromVersionCode(known) + return UpdatePreferences.get(appContext).usableUpdateOffer()?.version } /** diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateOffer.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateOffer.kt new file mode 100644 index 0000000..d7c490b --- /dev/null +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateOffer.kt @@ -0,0 +1,102 @@ +package ru.greemlab.neiro.update + +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * Найденное обновление, пережившее закрытие приложения. + * + * До этого результат проверки жил только в памяти `UpdateViewModel`, а на диске + * оставался один `last_known_version_code` — число, из которого нельзя ни + * скачать APK, ни показать «что изменилось». Поэтому уведомление о версии вело + * на экран, где предложение приходилось добывать заново кнопкой «Проверить + * обновления»: телефон знал про релиз, а приложение — нет. + * + * Здесь то же предложение записывается целиком, и экран «О программе» + * поднимает его при открытии — из уведомления, из настроек, офлайн, всё равно. + * + * Поля пишутся вручную, а не рефлексией Gson: `UpdateInfo` — не сетевая модель, + * правил R8 на неё нет, и обфусцированные имена полей однажды превратили бы + * сохранённое предложение в пустой объект уже после выпуска. + */ +object UpdateOffer { + + fun encode(info: UpdateInfo): String = JsonObject().apply { + addProperty(KEY_VERSION_CODE, info.version.versionCode) + addProperty(KEY_TITLE, info.title) + addProperty(KEY_NOTES, info.notes) + addProperty(KEY_RELEASE_URL, info.releaseUrl) + addProperty(KEY_APK_NAME, info.apkName) + addProperty(KEY_APK_URL, info.apkUrl) + addProperty(KEY_APK_SIZE, info.apkSizeBytes) + addProperty(KEY_CHECKSUMS_URL, info.checksumsUrl) + }.toString() + + /** + * @return null, если записи нет или она неполная. Неполной считается та, по + * которой нельзя нажать «Обновить»: без ссылки на APK или на файл сумм + * загрузка упадёт, а предложение на экране пообещает то, чего не будет. + */ + fun decode(json: String?): UpdateInfo? { + if (json.isNullOrBlank()) return null + val obj = try { + JsonParser.parseString(json).asJsonObject + } catch (_: Exception) { + // Чужой или обрезанный JSON: запись потеряна, проверка сходит заново. + return null + } + + val version = ReleaseVersion.fromVersionCode(obj.int(KEY_VERSION_CODE)) ?: return null + val apkUrl = obj.string(KEY_APK_URL) + val checksumsUrl = obj.string(KEY_CHECKSUMS_URL) + if (apkUrl.isBlank() || checksumsUrl.isBlank()) return null + + return UpdateInfo( + version = version, + title = obj.string(KEY_TITLE).ifBlank { "Neiro ${version.versionName}" }, + notes = obj.string(KEY_NOTES), + releaseUrl = obj.string(KEY_RELEASE_URL), + apkName = obj.string(KEY_APK_NAME), + apkUrl = apkUrl, + apkSizeBytes = obj.long(KEY_APK_SIZE), + checksumsUrl = checksumsUrl, + ) + } + + /** + * Стоит ли ещё показывать сохранённое предложение. Чистая функция без + * Android — здесь два случая, в которых запись обязана замолчать, и оба + * наступают без новой проверки: пользователь обновился (в том числе руками, + * из GitHub) и пользователь нажал «Пропустить». + * + * @return то же предложение или null, если показывать его больше нечего. + */ + fun usable( + info: UpdateInfo?, + installedVersionCode: Int, + skippedVersionCode: Int, + ): UpdateInfo? { + val version = info?.version ?: return null + if (!version.isNewerThan(installedVersionCode)) return null + if (version.versionCode <= skippedVersionCode) return null + return info + } + + private fun JsonObject.string(key: String): String = + get(key)?.takeIf { it.isJsonPrimitive }?.asString.orEmpty() + + private fun JsonObject.int(key: String): Int = + get(key)?.takeIf { it.isJsonPrimitive }?.asInt ?: 0 + + private fun JsonObject.long(key: String): Long = + get(key)?.takeIf { it.isJsonPrimitive }?.asLong ?: 0L + + private const val KEY_VERSION_CODE = "version_code" + private const val KEY_TITLE = "title" + private const val KEY_NOTES = "notes" + private const val KEY_RELEASE_URL = "release_url" + private const val KEY_APK_NAME = "apk_name" + private const val KEY_APK_URL = "apk_url" + private const val KEY_APK_SIZE = "apk_size" + private const val KEY_CHECKSUMS_URL = "checksums_url" +} diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt index 5782b69..6e6bba8 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt @@ -70,6 +70,47 @@ class UpdatePreferences(context: Context) : UpdateCheckStore { get() = prefs.getInt(KEY_SKIPPED_VERSION_CODE, 0) set(value) = prefs.edit().putInt(KEY_SKIPPED_VERSION_CODE, value).apply() + /** + * Последнее найденное обновление целиком — то самое предложение, которое + * показывает экран «О программе». + * + * Отдельно от [lastKnownVersionCode] потому, что это разные вопросы: + * там — «что вообще лежит в релизе» (нужно проверке и троттлингу, пишется + * даже для версии не новее нашей), здесь — «что предложить пользователю». + * Экран и точка «доступна версия» читают только это поле, иначе получились + * бы две копии одного ответа, расходящиеся при откате релиза. + * + * Пишется в [UpdateCheckCoordinator.checkNow] — единственной точке проверки. + */ + var availableUpdate: UpdateInfo? + get() = UpdateOffer.decode(prefs.getString(KEY_AVAILABLE_UPDATE, null)) + set(value) { + val editor = prefs.edit() + if (value == null) { + editor.remove(KEY_AVAILABLE_UPDATE) + } else { + editor.putString(KEY_AVAILABLE_UPDATE, UpdateOffer.encode(value)) + } + editor.apply() + } + + /** + * Предложение, которое ещё имеет смысл показывать: не про установленную + * версию и не про пропущенную. + * + * Устаревшую запись стираем на месте — иначе «Пропустить», нажатое на + * экране, пришлось бы дублировать в каждом читателе. + */ + fun usableUpdateOffer(): UpdateInfo? { + val offer = UpdateOffer.usable( + info = availableUpdate, + installedVersionCode = BuildConfig.VERSION_CODE, + skippedVersionCode = skippedVersionCode, + ) + if (offer == null) availableUpdate = null + return offer + } + /** Скачанный и проверенный, но ещё не установленный APK. */ var pendingApkPath: String? get() = prefs.getString(KEY_PENDING_APK_PATH, null) @@ -132,6 +173,7 @@ class UpdatePreferences(context: Context) : UpdateCheckStore { .remove(KEY_LAST_CHECK_EPOCH) .remove(KEY_LAST_KNOWN_VERSION_CODE) .remove(KEY_RATE_LIMITED_UNTIL) + .remove(KEY_AVAILABLE_UPDATE) .apply() } @@ -146,6 +188,7 @@ class UpdatePreferences(context: Context) : UpdateCheckStore { private const val KEY_PENDING_APK_PATH = "pending_apk_path" private const val KEY_PENDING_VERSION_CODE = "pending_version_code" private const val KEY_UPDATED_FROM_VERSION_CODE = "updated_from_version_code" + private const val KEY_AVAILABLE_UPDATE = "available_update" @Volatile private var instance: UpdatePreferences? = null diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt index 6e562e3..3a94762 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt @@ -57,21 +57,33 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) init { UpdateChannelGate.blockReason(application)?.let { _state.value = UpdateState.Blocked(it) } - restoreAfterInstall() + restorePreviousState() observeInstallStatus() } /** - * Что осталось от прошлого запуска: отметка об установке и, может быть, - * скачанный, но не поставленный APK. Второе важнее, чем кажется: без него - * человек, закрывший системный диалог, качал бы те же 15 МБ заново. + * Что осталось от прошлого запуска: отметка об установке, скачанный, но не + * поставленный APK, и найденное обновление, которое ещё не поставили. + * + * Последнее — то, ради чего экран открывается готовым: человек, пришедший + * по уведомлению о версии, видит предложение обновиться сразу, а не жмёт + * «Проверить обновления», чтобы приложение узнало то, что уже знает. + * Скачанный APK при этом важнее найденной версии: качать те же 15 МБ + * заново из-за закрытого системного диалога незачем. */ - private fun restoreAfterInstall() { + private fun restorePreviousState() { + // Сборке из магазина или debug обновляться нечем: ни предложения, ни + // готового файла показывать нельзя, даже если они остались от прошлой. + if (_state.value is UpdateState.Blocked) return + val updatedFrom = preferences.consumeUpdatedFrom() if (updatedFrom in 1 until BuildConfig.VERSION_CODE) { _justUpdatedTo.value = BuildConfig.VERSION_NAME UpdateDownloader.clearDownloads(app) preferences.clearPendingApk() + // Предложение исполнено — иначе экран предлагал бы поставить то, + // что только что поставили. + preferences.availableUpdate = null UpdateNotifier.cancel(app) return } @@ -84,6 +96,7 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) UpdateDownloader.clearDownloads(app) preferences.clearPendingApk() } + restoreOffer() return } @@ -91,6 +104,7 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) val version = ReleaseVersion.fromVersionCode(pendingVersion) if (!apk.isFile || apk.length() == 0L || version == null) { preferences.clearPendingApk() + restoreOffer() return } @@ -108,6 +122,15 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) _state.value = UpdateState.ReadyToInstall(info, apk) } + /** + * Найденное прошлой проверкой обновление — на экран, без похода в сеть. + * Устаревшую запись (обновились, пропустили) отсекает само хранилище. + */ + private fun restoreOffer() { + val offer = preferences.usableUpdateOffer() ?: return + _state.value = UpdateState.Available(offer) + } + /** Отметку прочитали и показали — второй раз не надо. */ fun dismissJustUpdated() { _justUpdatedTo.value = null @@ -205,6 +228,7 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) preferences.notifiedVersionCode, info.version.versionCode, ) + preferences.availableUpdate = null UpdateNotifier.cancel(app) UpdateDownloader.clearDownloads(app) preferences.clearPendingApk() @@ -241,8 +265,13 @@ class UpdateViewModel(application: Application) : AndroidViewModel(application) val info = _state.value.info when (event) { null -> Unit - is UpdateInstallEvent.Installed -> _state.value = - UpdateState.UpToDate(preferences.lastCheckEpochMillis) + is UpdateInstallEvent.Installed -> { + // Поставили — предлагать больше нечего. Обычно процесс + // после установки убивают, но если он дожил, экран не + // должен звать обновляться до уже стоящей версии. + preferences.availableUpdate = null + _state.value = UpdateState.UpToDate(preferences.lastCheckEpochMillis) + } is UpdateInstallEvent.AwaitingConfirmation -> if (info != null) _state.value = UpdateState.AwaitingConfirmation(info) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e8f626..70381c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,7 +171,7 @@ Обновления приложения Сообщения о новых версиях Neiro Neiro %1$s — доступно обновление - Нажмите, чтобы посмотреть, что изменилось + Нажмите, чтобы обновиться Пропустить Обновление готово Нажмите, чтобы установить новую версию diff --git a/app/src/test/java/ru/greemlab/neiro/update/UpdateOfferTest.kt b/app/src/test/java/ru/greemlab/neiro/update/UpdateOfferTest.kt new file mode 100644 index 0000000..e076da8 --- /dev/null +++ b/app/src/test/java/ru/greemlab/neiro/update/UpdateOfferTest.kt @@ -0,0 +1,77 @@ +package ru.greemlab.neiro.update + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Сохранённое предложение обновиться: то, из-за чего экран «О программе» + * открывается готовым, а не с пустой кнопкой «Проверить обновления». + * + * Проверяется здесь, а не глазами на телефоне, потому что ломается это тихо: + * запись переживает перезапуск, и неверно разобранное поле выяснилось бы уже + * после выпуска — предложением, которое не скачивается. + */ +class UpdateOfferTest { + + private val info = UpdateInfo( + version = ReleaseVersion(0, 2, 4), + title = "Neiro 0.2.4", + notes = "- Разбор дня по плиткам\n- Мелкие правки", + releaseUrl = "https://github.com/Greem4/neiro/releases/tag/v0.2.4", + apkName = "neiro-0.2.4.apk", + apkUrl = "https://github.com/Greem4/neiro/releases/download/v0.2.4/neiro-0.2.4.apk", + apkSizeBytes = 15_728_640, + checksumsUrl = "https://github.com/Greem4/neiro/releases/download/v0.2.4/SHA256SUMS", + ) + + @Test + fun `запись переживает круг через строку без потерь`() { + assertEquals(info, UpdateOffer.decode(UpdateOffer.encode(info))) + } + + @Test + fun `пустой строки и мусора не пугаемся`() { + assertNull(UpdateOffer.decode(null)) + assertNull(UpdateOffer.decode("")) + assertNull(UpdateOffer.decode("{\"version_code\":")) + assertNull(UpdateOffer.decode("не json вовсе")) + } + + @Test + fun `без ссылки на APK предложения нет`() { + // Кнопка «Обновить» по такой записи привела бы к ошибке загрузки. + val json = UpdateOffer.encode(info.copy(apkUrl = "")) + assertNull(UpdateOffer.decode(json)) + } + + @Test + fun `без файла сумм предложения нет`() { + // Скачать вышло бы, а сверить — нет: установка такого APK запрещена. + val json = UpdateOffer.encode(info.copy(checksumsUrl = "")) + assertNull(UpdateOffer.decode(json)) + } + + @Test + fun `версию новее показываем`() { + val usable = UpdateOffer.usable(info, installedVersionCode = 203, skippedVersionCode = 0) + assertEquals(info, usable) + } + + @Test + fun `после установки предложение молчит`() { + // Обновиться могли и мимо приложения — руками из GitHub. + assertNull(UpdateOffer.usable(info, installedVersionCode = 204, skippedVersionCode = 0)) + } + + @Test + fun `пропущенная версия молчит`() { + assertNull(UpdateOffer.usable(info, installedVersionCode = 203, skippedVersionCode = 204)) + } + + @Test + fun `следующая после пропущенной снова показывается`() { + val next = info.copy(version = ReleaseVersion(0, 2, 5)) + assertEquals(next, UpdateOffer.usable(next, installedVersionCode = 203, skippedVersionCode = 204)) + } +} diff --git a/docs/updater/ARCHITECTURE.md b/docs/updater/ARCHITECTURE.md index ccae494..f1e8049 100644 --- a/docs/updater/ARCHITECTURE.md +++ b/docs/updater/ARCHITECTURE.md @@ -258,7 +258,8 @@ suspend fun check(force: Boolean = false): UpdateStatus |---|---| | `auto_check_enabled` | Проверять автоматически (по умолчанию `true`) | | `last_check_epoch` | Когда последний раз спрашивали GitHub — успешно или нет | -| `last_known_version_code` | Что видели в прошлый раз (кэш для офлайна) | +| `last_known_version_code` | Что видели в прошлый раз (кэш для офлайна и троттлинга) | +| `available_update` | Найденное обновление целиком (JSON, `UpdateOffer`) — предложение, которое поднимает экран при открытии | | `rate_limited_until` | Лимит GitHub исчерпан — до этого времени не ходим даже по кнопке | | `notified_version_code` | О какой версии уже уведомляли — чтобы не звонить каждый день об одном и том же | | `skipped_version_code` | «Пропустить эту версию» — молчим, пока не выйдет следующая | @@ -448,7 +449,18 @@ release.yml ──► POST /v1/release/notify ──► neiro-push ──► FCM Отдельный канал `app_updates`, важность `DEFAULT` (не срочно, звенеть не должно), оформление — общий `NeiroNotificationBranding.apply`, как у остальных уведомлений приложения. Текст: «Neiro 0.2.0 — доступно обновление», в теле — -первая строка описания релиза. Нажатие открывает экран «О программе». +первая строка описания релиза. Нажатие открывает экран «О программе», и экран +открывается **готовым**: карточка «Доступна 0.2.0» с кнопкой «Обновить» уже +стоит там, второй раз проверять обновления не надо. + +Держится это на `available_update`: найденное предложение записывается в +`UpdateCheckCoordinator.checkNow` целиком и переживает закрытие приложения, а +`UpdateViewModel` поднимает его при создании. Прокидывать версию через +`Intent` было бы дешевле, но починило бы только путь из уведомления — экран, +открытый из настроек или без сети, остался бы пустым. + +Запись стирается тогда же, когда перестаёт быть правдой: проверка вернула +«новее нечего», пользователь нажал «Пропустить», обновление установилось. Об одной и той же версии уведомляем один раз (`notified_version_code`). Кнопка «Пропустить» в уведомлении пишет `skipped_version_code` — молчим до @@ -504,6 +516,12 @@ release.yml ──► POST /v1/release/notify ──► neiro-push ──► FCM └────────────────────────────────────────┘ ``` +Карточка «Доступна 0.2.0» появляется не только после нажатия «Проверить +обновления»: при открытии экрана `UpdateViewModel` поднимает сохранённое +предложение (`available_update`) — из уведомления, из настроек, офлайн, всё +равно. Скачанный, но не поставленный APK при этом важнее: если он есть, экран +показывает «Установить», а не предлагает качать те же 15 МБ заново. + Описание релиза приходит Markdown-ом из `body`. Рендерить Markdown нечем и не нужно: показываем как есть, обрезая до разумной длины, со ссылкой «Открыть на GitHub» для полного текста. @@ -521,7 +539,11 @@ GitHub» для полного текста. отсутствие нужного имени; - выбор ассета: точное имя, единственный `.apk`, два `.apk` → ошибка, ни одного → ошибка; - политика уведомлений: та же версия дважды, пропущенная версия, следующая - после пропущенной. + после пропущенной; +- сохранённое предложение (`UpdateOffer`): круг через строку без потерь, мусор + вместо JSON, запись без ссылки на APK или на файл сумм, а также когда + предложение обязано замолчать — обновились (в том числе мимо приложения) и + «Пропустить». Проверяется на устройстве вручную (чек-лист — в [TASKS.md](TASKS.md#этап-11--проверка-на-устройстве)): установка поверх, сохранность данных, отказ пользователя, отсутствие сети diff --git a/docs/updater/TASKS.md b/docs/updater/TASKS.md index 355d2a5..002d1b5 100644 --- a/docs/updater/TASKS.md +++ b/docs/updater/TASKS.md @@ -558,6 +558,9 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв - [x] Строки — в `strings.xml` - [x] Уведомление открывает экран напрямую: `EXTRA_OPEN_ABOUT` → `MainActivity` → `CalendarScreen` +- [x] Экран открывается готовым: найденное обновление сохраняется целиком + (`available_update`, `UpdateOffer`) и поднимается при создании + `UpdateViewModel` — повторная проверка не нужна Макет — [ARCHITECTURE.md § Экран «О программе»](ARCHITECTURE.md#экран-о-программе). From 40da99e7f3f062d70f9dac0a505037001191c35c Mon Sep 17 00:00:00 2001 From: Greem4 Date: Tue, 1 Sep 2026 15:21:27 +0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(calendar):=20=D1=87=D0=B8=D0=BD=D1=8E?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B0=D0=B6=D1=83=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D0=B8=D0=B9,=20=D0=BD=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3,=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20=C2=AB=D0=A1=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Догон по changed_after отдаёт только изменившиеся записи, а слияние вне текущего месяца вычищало из дня всё, чего в ответе не было: после уведомления в дне оставалась одна запись. Налог месяца копировался в историю при первом синке и оттуда читался — однажды введённая ошибочно сумма жила вечно. Теперь источник один: профиль. «Пришёл» в YClients оплатой не является. Добавлен статус «Оплачено» (paid_full): значки повторяют YClients, «проведено» считается по приходу, деньги — по оплате. Кнопка «Сегодня» перекрывала нижний ряд чисел при крупном системном шрифте — место под неё считается по её настоящей высоте. Пустой fcm_token при входе больше не затирает рабочий, а причина, по которой Firebase не отдал токен, видна на экране уведомлений. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M1hENvmR48YvNMmoc1Pzvz --- CHANGELOG.md | 27 ++++++ .../ru/greemlab/neiro/NeiroApplication.kt | 4 + .../greemlab/neiro/data/SalaryLedgerRules.kt | 5 +- .../neiro/data/network/YClientsModels.kt | 7 ++ .../neiro/data/network/YClientsRepository.kt | 11 ++- .../notifications/SessionChangeDetector.kt | 5 +- .../neiro/push/PushDeliveryDiagnostics.kt | 93 +++++++++++++++++++ .../neiro/push/PushEventCalendarApplier.kt | 8 ++ .../ru/greemlab/neiro/push/PushFcmToken.kt | 21 ++++- .../ru/greemlab/neiro/push/PushRegistrar.kt | 4 +- .../neiro/sync/YClientsCalendarSync.kt | 43 ++++++++- .../neiro/ui/calendar/ArchiveSyncCompare.kt | 1 + .../ui/calendar/CalendarStatsCalculator.kt | 16 +++- .../neiro/ui/calendar/DaySummaryStats.kt | 13 ++- .../ui/calendar/IntensiveSessionUtils.kt | 6 +- .../neiro/ui/calendar/MonthRatesResolver.kt | 24 +++-- .../neiro/ui/calendar/SessionParser.kt | 76 ++++++++++++--- .../neiro/ui/components/DayDetailsDialog.kt | 5 +- .../daydetails/AttendanceStatusPicker.kt | 1 + .../daydetails/AttendanceStatusVisuals.kt | 16 +++- .../daydetails/IntensiveDetailsDialog.kt | 4 +- .../ui/components/daydetails/ScheduleTime.kt | 3 +- .../neiro/ui/screens/CalendarScreen.kt | 25 ++++- .../SessionNotificationSettingsScreen.kt | 74 +++++++++++++++ .../SessionNotificationSettingsViewModel.kt | 26 ++++++ app/src/main/res/values/strings.xml | 7 ++ .../neiro/data/SalaryLedgerRulesTest.kt | 4 +- .../SessionChangeDetectorTest.kt | 18 ++++ .../push/PushEventCalendarApplierTest.kt | 31 +++++++ .../neiro/sync/DailySessionRateTest.kt | 4 +- .../neiro/sync/YClientsCalendarSyncTest.kt | 44 +++++++++ .../calendar/CalendarStatsCalculatorTest.kt | 8 +- .../neiro/ui/calendar/DaySummaryStatsTest.kt | 38 +++++--- .../ui/calendar/IntensiveSessionUtilsTest.kt | 7 +- .../ui/calendar/MonthRatesResolverTest.kt | 39 ++++++-- .../neiro/ui/calendar/ProfileYearStatsTest.kt | 37 ++++---- .../neiro/ui/calendar/SessionParserTest.kt | 36 +++++++ docs/neiro-push/API.md | 6 ++ docs/pricing-from-api/API-HOWTO.md | 8 ++ gradle.properties | 3 + neiro-push/app/database.py | 12 ++- neiro-push/tests/test_database.py | 19 ++++ 42 files changed, 746 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/ru/greemlab/neiro/push/PushDeliveryDiagnostics.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c0453..bea364a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ ## [Не выпущено] +### Добавлено + +- **Оплата видна отдельно от прихода** — как в YClients: клиент пришёл — + на записи восклицательный знак, заплатил — плюсик. «Проведено» считает + всех, кто пришёл, а в «Заработано» занятие попадает только после оплаты; + неоплаченное стоит в «Ожидается», пока деньги не пришли. + +### Исправлено + +- **Занятия дня больше не пропадают после уведомления** — когда приходило + уведомление про запись на следующий месяц, в этом дне оставалась только она + одна, а остальные занятия исчезали до следующей полной синхронизации. +- **Налог из настроек применяется ко всем месяцам** — исправленная сумма + налога теперь меняет прибыль и в прошлых месяцах. Раньше первая введённая + цифра оставалась в истории навсегда, и месяц показывал доход, посчитанный + по ней. +- **Кнопка «Сегодня» не закрывает числа** — при увеличенном системном шрифте + она перекрывала нижний ряд календаря, и по этим дням нельзя было нажать. + Место под кнопку теперь отводится по её настоящему размеру. +- **Видно, когда уведомления идут с задержкой** — если телефон не получил от + Google ключ мгновенной доставки, приложение говорит об этом на экране + «Уведомления о занятиях» и предлагает проверить снова. Раньше уведомления + молча приходили с опозданием до получаса. +- **Вход не отключает пуши** — если при входе телефон ещё не получил ключ от + Google, сервер больше не стирает рабочий: уведомления продолжают приходить + мгновенно. + ### Изменено - **Уведомление об обновлении ведёт прямо к кнопке** — нажатие на «доступно diff --git a/app/src/main/java/ru/greemlab/neiro/NeiroApplication.kt b/app/src/main/java/ru/greemlab/neiro/NeiroApplication.kt index 2aa3f65..94f6edc 100644 --- a/app/src/main/java/ru/greemlab/neiro/NeiroApplication.kt +++ b/app/src/main/java/ru/greemlab/neiro/NeiroApplication.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import ru.greemlab.neiro.data.CalendarDataStoreProvider import ru.greemlab.neiro.notifications.SessionNotificationCoordinator +import ru.greemlab.neiro.push.PushDeliveryDiagnostics import ru.greemlab.neiro.push.PushRegistrar import ru.greemlab.neiro.sync.AutoSyncCoordinator import ru.greemlab.neiro.sync.LiveApiCoordinator @@ -34,6 +35,9 @@ class NeiroApplication : Application() { LiveApiCoordinator.initialize(this@NeiroApplication) SessionNotificationCoordinator.initialize(this@NeiroApplication) + // До PushRegistrar: тот сразу пойдёт за токеном FCM и запишет + // результат, а прогрев поверх него вернул бы сохранённое старое. + PushDeliveryDiagnostics.warmUp(this@NeiroApplication) PushRegistrar.initialize(this@NeiroApplication) // Суточная проверка новой версии на GitHub. В debug выходит сразу: diff --git a/app/src/main/java/ru/greemlab/neiro/data/SalaryLedgerRules.kt b/app/src/main/java/ru/greemlab/neiro/data/SalaryLedgerRules.kt index c98d5aa..fdf6fc8 100644 --- a/app/src/main/java/ru/greemlab/neiro/data/SalaryLedgerRules.kt +++ b/app/src/main/java/ru/greemlab/neiro/data/SalaryLedgerRules.kt @@ -154,7 +154,10 @@ fun mergeFact( ?: profile.pricePerDiagnostics, priceIntensiveChild = existing?.priceIntensiveChild?.takeIf { it > 0.0 } ?: profile.pricePerIntensiveChild, - tax = existing?.tax?.takeIf { it > 0.0 } ?: profile.monthlyTaxAmount, + // След, а не источник: расчёт месяца берёт налог из профиля + // (`MonthRatesResolver`). Держим здесь свежее значение, чтобы запись + // месяца не хранила давно исправленную сумму. + tax = profile.monthlyTaxAmount, factGross = if (hasFact) fact.gross else existing?.factGross, factSessions = if (hasFact) fact.services else existing?.factSessions, origin = existing?.origin ?: PriceOrigin.AUTO, diff --git a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsModels.kt b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsModels.kt index 156b37a..015c06b 100644 --- a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsModels.kt +++ b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsModels.kt @@ -31,6 +31,13 @@ data class RecordData( @SerializedName("seance_length") val seanceLength: Int?, val length: Int?, @SerializedName("visit_attendance") val visitAttendance: Int?, + /** + * Оплачено ли занятие — плюсик на записи в YClients. `1` — да. + * + * Отдельно от [attendance]: «пришёл» деньгами не является, и в заработок + * занятие попадает только с оплатой (решение от 01.09.2026). + */ + @SerializedName("paid_full") val paidFull: Int? = null, val deleted: Boolean? = null, @SerializedName("last_change_date") val lastChangeDate: String? = null, /** Групповое событие (интенсив и прочие группы). У обычной записи `null`. */ diff --git a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt index 842067d..a3147f9 100644 --- a/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt +++ b/app/src/main/java/ru/greemlab/neiro/data/network/YClientsRepository.kt @@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicInteger import ru.greemlab.neiro.BuildConfig import ru.greemlab.neiro.auth.LogoutCoordinator import ru.greemlab.neiro.push.PushConfig +import ru.greemlab.neiro.push.PushDeliveryDiagnostics import ru.greemlab.neiro.push.PushDeviceId import ru.greemlab.neiro.push.PushEventsCursor import ru.greemlab.neiro.push.PushFcmToken @@ -107,7 +108,7 @@ class YClientsRepository(context: Context) { // Токен FCM берём до входа: сервер регистрирует устройство тем же // запросом. Не дал — не беда, вход пройдёт и без пушей, а токен // донесёт PushRegistrar, когда Firebase его выдаст. - val fcmToken = PushFcmToken.fetch().orEmpty() + val fcmToken = PushFcmToken.fetch(appContext).orEmpty() try { val response = neiroApi.login( LoginRequest( @@ -152,6 +153,9 @@ class YClientsRepository(context: Context) { // Курсор событий: новое устройство стартует с конца журнала, // известное — со своего сохранённого места (API.md § Вход). PushEventsCursor.setIfAbsent(appContext, body.lastEventId) + // Устройство регистрируется тем же запросом: непустой токен, + // уехавший с логином, сервер уже принял. + if (fcmToken.isNotBlank()) PushDeliveryDiagnostics.onTokenDelivered(appContext) ApiResult.Success(account) } catch (e: CancellationException) { throw e @@ -237,6 +241,11 @@ class YClientsRepository(context: Context) { .onSuccess { handleAuthFailure(it.code(), generation) } .map { it.isSuccessful } .getOrDefault(false) + .also { delivered -> + // Пуши идут только на устройство, чей токен доехал до сервера; + // до этого момента приложение живёт на догоне. + if (delivered) PushDeliveryDiagnostics.onTokenDelivered(appContext) + } } /** diff --git a/app/src/main/java/ru/greemlab/neiro/notifications/SessionChangeDetector.kt b/app/src/main/java/ru/greemlab/neiro/notifications/SessionChangeDetector.kt index 7507f76..002aae9 100644 --- a/app/src/main/java/ru/greemlab/neiro/notifications/SessionChangeDetector.kt +++ b/app/src/main/java/ru/greemlab/neiro/notifications/SessionChangeDetector.kt @@ -74,7 +74,10 @@ object SessionChangeDetector { afterSession.status == AttendanceStatus.CONFIRMED -> events += SessionEvent(SessionEventType.CLIENT_CONFIRMED, afterSession, beforeSession) - afterSession.status == AttendanceStatus.ARRIVED -> + // Оплата приходит вместе с приходом или сразу за ним: событие + // ставится один раз, на сам факт «клиент пришёл», а переход + // «пришёл → оплачено» человека не будит (01.09.2026). + afterSession.status.hasArrived && !beforeSession.status.hasArrived -> events += SessionEvent(SessionEventType.CLIENT_ARRIVED, afterSession, beforeSession) else -> Unit diff --git a/app/src/main/java/ru/greemlab/neiro/push/PushDeliveryDiagnostics.kt b/app/src/main/java/ru/greemlab/neiro/push/PushDeliveryDiagnostics.kt new file mode 100644 index 0000000..34f346a --- /dev/null +++ b/app/src/main/java/ru/greemlab/neiro/push/PushDeliveryDiagnostics.kt @@ -0,0 +1,93 @@ +package ru.greemlab.neiro.push + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Состояние канала мгновенных уведомлений — то, что видно человеку. + * + * Пуш приходит только на устройство с живым токеном FCM. Токен Firebase выдаёт + * не всегда: без Google-сервисов, при их старой версии или заблокированном + * доступе к серверам Google `getToken()` просто не отвечает. Раньше это было + * молчаливым исходом — токен не приезжал, приложение жило на получасовом + * догоне, и снаружи это выглядело как «уведомления приходят, но с задержкой» + * (IN2010, 01.09.2026: обе установки неделю прожили с пустым токеном). + * + * Здесь состояние хранится, чтобы его можно было показать на экране настроек + * уведомлений: работает канал или нет, и почему. + */ +object PushDeliveryDiagnostics { + + private const val PREFS_NAME = "push_diagnostics" + private const val KEY_LAST_TOKEN_AT = "last_token_at" + private const val KEY_LAST_DELIVERED_AT = "last_delivered_at" + private const val KEY_LAST_ERROR = "last_error" + + /** Что известно про канал пушей прямо сейчас. */ + data class State( + /** Firebase хоть раз отдал токен на этом устройстве. */ + val hasToken: Boolean = false, + /** Токен доехал до push-сервера — с этого момента пуши могут приходить. */ + val deliveredToServer: Boolean = false, + /** Почему Firebase не отдал токен; пусто — ошибки не было. */ + val lastError: String = "", + /** Сборка вообще умеет пуши: есть google-services.json и адрес сервера. */ + val isSupported: Boolean = PushConfig.isActive, + ) { + /** Мгновенные уведомления работают: токен есть и сервер о нём знает. */ + val isWorking: Boolean get() = isSupported && hasToken && deliveredToServer + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + /** Читает сохранённое состояние — вызывать при старте приложения. */ + fun warmUp(context: Context) { + val prefs = prefs(context) + _state.value = State( + hasToken = prefs.getLong(KEY_LAST_TOKEN_AT, 0L) > 0L, + deliveredToServer = prefs.getLong(KEY_LAST_DELIVERED_AT, 0L) > 0L, + lastError = prefs.getString(KEY_LAST_ERROR, "").orEmpty(), + ) + } + + /** Firebase отдал токен. */ + fun onTokenReceived(context: Context) { + prefs(context).edit() + .putLong(KEY_LAST_TOKEN_AT, System.currentTimeMillis()) + .remove(KEY_LAST_ERROR) + .apply() + _state.value = _state.value.copy(hasToken = true, lastError = "") + } + + /** + * Firebase токен не отдал. [reason] — текст исключения: без него «нет + * Google-сервисов», «нет сети» и «Firebase не настроен» выглядят одинаково. + */ + fun onTokenFailed(context: Context, reason: String) { + val trimmed = reason.take(MAX_REASON_LENGTH) + prefs(context).edit().putString(KEY_LAST_ERROR, trimmed).apply() + _state.value = _state.value.copy(lastError = trimmed) + } + + /** Токен принят push-сервером. */ + fun onTokenDelivered(context: Context) { + prefs(context).edit() + .putLong(KEY_LAST_DELIVERED_AT, System.currentTimeMillis()) + .apply() + _state.value = _state.value.copy(deliveredToServer = true) + } + + /** Выход из аккаунта: устройство на сервере отозвано, знание о нём устарело. */ + fun onLogout(context: Context) { + prefs(context).edit().remove(KEY_LAST_DELIVERED_AT).apply() + _state.value = _state.value.copy(deliveredToServer = false) + } + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private const val MAX_REASON_LENGTH = 200 +} diff --git a/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt b/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt index fb2d7ef..f93da10 100644 --- a/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt +++ b/app/src/main/java/ru/greemlab/neiro/push/PushEventCalendarApplier.kt @@ -91,6 +91,14 @@ object PushEventCalendarApplier { // Интенсивы правит только полный синк — слияние по детям нетривиально (§5.6). if (SessionParser.isIntensive(raw)) return dayData + // Событие «пришёл» не отменяет уже известную оплату: про `paid_full` + // сервер не знает, и понижение статуса вернуло бы деньги дня в + // ожидаемые до следующего полного синка (01.09.2026). + val current = SessionParser.parse(raw).status + if (status.mergePriority < current.mergePriority && status != AttendanceStatus.CANCELLED) { + return dayData + } + val updated = found.entries.toMutableList() updated[found.index] = SessionParser.withStatus(raw, status) return dayData + (date to updated) diff --git a/app/src/main/java/ru/greemlab/neiro/push/PushFcmToken.kt b/app/src/main/java/ru/greemlab/neiro/push/PushFcmToken.kt index 652d0bb..cefc130 100644 --- a/app/src/main/java/ru/greemlab/neiro/push/PushFcmToken.kt +++ b/app/src/main/java/ru/greemlab/neiro/push/PushFcmToken.kt @@ -1,5 +1,7 @@ package ru.greemlab.neiro.push +import android.content.Context +import android.util.Log import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -11,18 +13,31 @@ import kotlin.coroutines.resume * * `null` — штатный исход: FCM выключен в сборке, на устройстве нет * Google-сервисов или Firebase не ответил. Ни вход, ни работа с данными от - * этого не зависят, теряются только пуши. + * этого не зависят, теряются только пуши — но молчать об этом нельзя: + * причина неудачи уходит в [PushDeliveryDiagnostics] и оттуда на экран + * настроек уведомлений. */ object PushFcmToken { - suspend fun fetch(): String? { + private const val TAG = "PushFcmToken" + + suspend fun fetch(context: Context): String? { if (!PushConfig.isFcmEnabled) return null + val appContext = context.applicationContext return suspendCancellableCoroutine { cont -> val task = com.google.firebase.messaging.FirebaseMessaging.getInstance().token task.addOnSuccessListener { value -> + PushDeliveryDiagnostics.onTokenReceived(appContext) if (cont.isActive) cont.resume(value) } - task.addOnFailureListener { + task.addOnFailureListener { error -> + // Без текста ошибки «нет Google-сервисов», «нет сети» и + // «Firebase не настроен» выглядят одинаково — молчащим null. + Log.w(TAG, "Firebase не отдал токен", error) + PushDeliveryDiagnostics.onTokenFailed( + appContext, + error.message ?: error::class.java.simpleName, + ) if (cont.isActive) cont.resume(null) } cont.invokeOnCancellation { diff --git a/app/src/main/java/ru/greemlab/neiro/push/PushRegistrar.kt b/app/src/main/java/ru/greemlab/neiro/push/PushRegistrar.kt index aca8883..b2fcb6c 100644 --- a/app/src/main/java/ru/greemlab/neiro/push/PushRegistrar.kt +++ b/app/src/main/java/ru/greemlab/neiro/push/PushRegistrar.kt @@ -58,6 +58,8 @@ object PushRegistrar { // Иначе после входа под другим аккаунтом догон начнётся с чужого id // и пропустит его события (app.md §6.5). PushEventsCursor.reset(appContext) + // Устройство на сервере отзывается — знание «токен там есть» устарело. + PushDeliveryDiagnostics.onLogout(appContext) if (!PushConfig.isServerConfigured) return if (!YClientsRepository.getInstance(appContext).revokeDeviceOnServer()) { Log.w(TAG, "отзыв устройства не прошёл, повторим при следующем старте") @@ -112,7 +114,7 @@ object PushRegistrar { val repository = YClientsRepository.getInstance(context) if (!repository.isLoggedIn.first()) return false repository.refreshSession() - PushFcmToken.fetch()?.let { repository.updateFcmToken(it) } + PushFcmToken.fetch(context)?.let { repository.updateFcmToken(it) } return repository.isLoggedIn.first() } } diff --git a/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt b/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt index 6db9fd6..3b08d89 100644 --- a/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt +++ b/app/src/main/java/ru/greemlab/neiro/sync/YClientsCalendarSync.kt @@ -323,12 +323,23 @@ class YClientsCalendarSync( * Ученика или диагностику в synced-календаре руками не завести * (`CalendarViewModel.saveNamesForDate` берёт из ввода только интенсивы), * поэтому отбросить может лишь то, что и так пришло из YClients. + * + * Всё это верно, только когда [records] — полный ответ за диапазон. У + * инкрементального опроса (`changed_after`) в руках лишь изменившиеся + * записи, и «не найденное в API» там значит «не менялось», а не «снято». + * Для него есть [dropUnmatchedLocal] = `false`: день дополняется, а не + * пересобирается. + * + * @param dropUnmatchedLocal день авторитативен: локальные записи без пары + * в [records] удаляются (кроме ручных интенсивов). `false` — они + * остаются нетронутыми. */ private suspend fun mergeRecordsToCalendar( records: List, startDate: LocalDate, endDate: LocalDate, clearMissingDaysInRange: Boolean = true, + dropUnmatchedLocal: Boolean = true, ): DayMergeStats { val userProfile = calendarRepository.userProfileFlow.first() rememberRecordsMeta(records) @@ -414,7 +425,13 @@ class YClientsCalendarSync( newlyAdded += mergeIntensivesFromApi(intensiveDayRecords, userProfile, pool, syncedEntries) - val merged = syncedEntries + retainManualLocalEntries(pool) + // Из инкрементального опроса приходит только изменившееся: + // остальные занятия дня в `pool` живы и в YClients, выбросить + // их — оставить в дне одну запись из уведомления. + val merged = syncedEntries + survivingLocalEntries( + unmatched = pool.map { it.first }, + dropUnmatched = dropUnmatchedLocal, + ) if (merged != existingRaw) { currentDayData[date] = merged changedDays++ @@ -519,6 +536,9 @@ class YClientsCalendarSync( startDate = rangeStart, endDate = rangeEnd, clearMissingDaysInRange = false, + // `changed_after` отдаёт только изменившиеся записи: остальные + // занятия дня в ответе не участвуют и удалению не подлежат. + dropUnmatchedLocal = false, ).newlyAdded } @@ -577,7 +597,7 @@ class YClientsCalendarSync( * сети, пустом подозрительном ответе или обрезанной пагинации календарь не трогаем. */ private fun retainManualLocalEntries(pool: List>): List = - pool.map { it.first }.filter { raw -> raw.startsWith(SessionFormat.INTENSIVE_PREFIX) } + survivingLocalEntries(unmatched = pool.map { it.first }, dropUnmatched = true) private fun isIntensiveRecord(record: RecordData): Boolean = record.services?.any { it.title?.contains("интенсив", ignoreCase = true) == true } == true @@ -871,7 +891,7 @@ class YClientsCalendarSync( } private fun mapAttendanceStatus(record: RecordData): AttendanceStatus = - AttendanceStatus.resolveFromRecord(record.attendance, record.visitAttendance) + AttendanceStatus.resolveFromRecord(record.attendance, record.visitAttendance, record.paidFull) private fun formatRecordTime(record: RecordData, intensive: Boolean = false): String { val datetime = record.datetime ?: return "" @@ -1002,6 +1022,23 @@ class YClientsCalendarSync( return amount to false } + /** + * Локальные записи дня, оставшиеся без пары в ответе API. + * + * [dropUnmatched] = `true` — ответ полный, и «не пришло из API» значит + * «снято в YClients»: остаются только ручные интенсивы. `false` — + * ответ инкрементальный (`changed_after`), в нём и не должно быть + * ничего, кроме изменившегося: остаётся весь день. + */ + internal fun survivingLocalEntries( + unmatched: List, + dropUnmatched: Boolean, + ): List = if (dropUnmatched) { + unmatched.filter { raw -> raw.startsWith(SessionFormat.INTENSIVE_PREFIX) } + } else { + unmatched + } + /** Локальные интенсивы, чей слот времени не пришёл из API. */ internal fun unmatchedLocalIntensives( existingLocal: List, diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/ArchiveSyncCompare.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/ArchiveSyncCompare.kt index 0f74c1e..ae7861c 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/ArchiveSyncCompare.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/ArchiveSyncCompare.kt @@ -188,6 +188,7 @@ object ArchiveSyncCompare { AttendanceStatus.CONFIRMED -> "Подтвердил" AttendanceStatus.CANCELLED -> "Не пришёл" AttendanceStatus.ARRIVED -> "Пришёл" + AttendanceStatus.PAID -> "Оплачено" } private fun canonicalDay(sessions: List): List = diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt index 5138a5f..9b27ace 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculator.kt @@ -94,6 +94,8 @@ internal fun computeMonthStats( when (session) { is Session.Intensive -> { + // Интенсив занятием не считается нигде (0.2.3), поэтому + // счётчик «проведено» его не касается — только деньги. if (session.countsTowardEarnings()) { val intensiveAmount = session.totalAmount( rates.pricePerIntensiveChild, @@ -120,9 +122,14 @@ internal fun computeMonthStats( is Session.Diagnostics -> { scheduled++ val price = if (rates.pricePerDiagnostics > 0.0) rates.pricePerDiagnostics else session.amount - if (session.countsTowardEarnings()) { + // «Проведено» — про работу, деньги — про оплату: клиент, + // который пришёл и ещё не заплатил, стоит в проведённых, а + // его деньги — в ожидаемых (01.09.2026). + if (session.countsAsAttended()) { completed++ completedDiagnostics++ + } + if (session.countsTowardEarnings()) { diagnosticsEarnings += price grossEarned += price } else { @@ -133,10 +140,13 @@ internal fun computeMonthStats( is Session.Student -> { scheduled++ val pay = rates.pricePerSession - val isAttended = session.countsTowardEarnings() + val isAttended = session.countsAsAttended() + val isPaid = session.countsTowardEarnings() if (isAttended) { completed++ completedSessions++ + } + if (isPaid) { grossEarned += pay } else { expectedIncome += pay @@ -148,7 +158,7 @@ internal fun computeMonthStats( studentStatsMap[name] = current.copy( completedCount = current.completedCount + (if (isAttended) 1 else 0), totalScheduled = current.totalScheduled + 1, - totalEarned = current.totalEarned + (if (isAttended) pay else 0.0) + totalEarned = current.totalEarned + (if (isPaid) pay else 0.0) ) } } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt index 8eb77c8..428d645 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt @@ -220,10 +220,13 @@ internal fun computeDayStats( totalLessons++ diagnosticsAcc.planned++ val price = if (rates.pricePerDiagnostics > 0.0) rates.pricePerDiagnostics else session.amount - if (session.countsTowardEarnings()) { + // Пришёл — уже в проведённых; в заработке — когда заплатил. + if (session.countsAsAttended()) { attendedLessons++ - earned += price diagnosticsAcc.attended++ + } + if (session.countsTowardEarnings()) { + earned += price diagnosticsAcc.earned += price } else { expected += price @@ -243,10 +246,12 @@ internal fun computeDayStats( totalLessons++ lessonsAcc.planned++ val pay = rates.pricePerSession - if (session.countsTowardEarnings()) { + if (session.countsAsAttended()) { attendedLessons++ - earned += pay lessonsAcc.attended++ + } + if (session.countsTowardEarnings()) { + earned += pay lessonsAcc.earned += pay } else { expected += pay diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtils.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtils.kt index 73e5b3e..a2306dd 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtils.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtils.kt @@ -59,10 +59,10 @@ fun Session.Intensive.totalAmount( /** Сколько детей подтвердились или уже пришли на интенсив. */ fun Session.Intensive.confirmedChildCount(): Int = when { children.isNotEmpty() -> children.count { - it.status == AttendanceStatus.CONFIRMED || it.status == AttendanceStatus.ARRIVED + it.status == AttendanceStatus.CONFIRMED || it.status.hasArrived } isEffectivelyDeleted() -> 0 - status == AttendanceStatus.CONFIRMED || status == AttendanceStatus.ARRIVED -> 1 + status == AttendanceStatus.CONFIRMED || status.hasArrived -> 1 else -> 0 } @@ -75,7 +75,7 @@ fun Session.Intensive.pendingChildCount(): Int = when { /** Сколько детей пришло на интенсив. */ fun Session.Intensive.arrivedChildCount(): Int = when { - children.isNotEmpty() -> children.count { it.status == AttendanceStatus.ARRIVED } + children.isNotEmpty() -> children.count { it.status.hasArrived } countsTowardEarnings() -> 1 else -> 0 } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolver.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolver.kt index a0c140d..a049c51 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolver.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolver.kt @@ -67,7 +67,14 @@ fun resolveMonthRates( // Профиль — правда про «сейчас и вперёд», история его не задевает. if (!month.isBefore(YearMonth.from(today))) return byProfile if (entry == null) return byProfile - if (entry.origin == PriceOrigin.MANUAL) return ResolvedRates(entry.rates(), PriceSource.MANUAL) + if (entry.origin == PriceOrigin.MANUAL) { + // Руками у месяца задаётся цена занятия, но не налог: налог — одна + // сумма из профиля, и она же должна вычитаться из любого месяца. + return ResolvedRates( + rates = entry.rates().copy(monthlyTaxAmount = profile.monthlyTaxAmount), + source = PriceSource.MANUAL, + ) + } // Цена месяца уже посчитана и лежит в истории — её и показываем, ничего не // выводя заново. Она взята из позиций начисления, где видна ставка каждого @@ -98,7 +105,7 @@ fun resolveMonthRates( pricePerSession = factPrice, pricePerDiagnostics = entry.diagnosticsPriceOr(profile), pricePerIntensiveChild = entry.intensivePriceOr(profile), - monthlyTaxAmount = entry.taxOr(profile), + monthlyTaxAmount = profile.monthlyTaxAmount, ), source = PriceSource.FACT, ) @@ -128,13 +135,18 @@ fun sessionPriceFromFact( * Ставки месяца из записи, с добором недостающего из профиля. * * `entry.rates()` брать нельзя: у записи, заведённой до появления поля, цена - * диагностики или налог лежат нулями, и месяц показал бы диагностику по 0 ₽. + * диагностики лежит нулём, и месяц показал бы диагностику по 0 ₽. + * + * Налог берётся из профиля всегда, а не из записи: задать его отдельно на + * месяц негде, в записи лежит слепок профиля на момент первого синка, и + * ошибочно введённая однажды сумма оставалась бы в истории навсегда + * (01.09.2026). */ internal fun MonthEntry.ratesOr(profile: EarningsContext): EarningsContext = EarningsContext( pricePerSession = pricePerSession, pricePerDiagnostics = diagnosticsPriceOr(profile), pricePerIntensiveChild = intensivePriceOr(profile), - monthlyTaxAmount = taxOr(profile), + monthlyTaxAmount = profile.monthlyTaxAmount, ) /** Цена диагностики месяца: из записи, если задана, иначе из профиля. */ @@ -145,10 +157,6 @@ internal fun MonthEntry?.diagnosticsPriceOr(profile: EarningsContext): Double = internal fun MonthEntry?.intensivePriceOr(profile: EarningsContext): Double = if (this != null && priceIntensiveChild > 0.0) priceIntensiveChild else profile.pricePerIntensiveChild -/** Налог месяца: из записи, если задан, иначе из профиля. */ -internal fun MonthEntry?.taxOr(profile: EarningsContext): Double = - if (this != null && tax > 0.0) tax else profile.monthlyTaxAmount - /** * Собирает суммы месяца из локальных записей — то, что нужно вычесть из факта * перед делением (FOUNDATION 3.2, контрольный день 19.06.2026). diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt index 3ed76fd..ae67c5f 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/SessionParser.kt @@ -9,20 +9,35 @@ import java.time.LocalDate * - [EXPECTED] (0) — ожидание, «+», в деньги не входит * - [CONFIRMED] (1) — подтвердил, что придёт, «галка», в деньги не входит * - [CANCELLED] (2) — не пришёл / отказ, «−», в деньги не входит - * - [ARRIVED] (3) — пришёл, занятие проведено, в деньги входит + * - [ARRIVED] (3) — пришёл, занятие проведено, но не оплачено: в деньги не входит + * - [PAID] (4) — оплачено, «плюсик» в YClients, в деньги входит */ enum class AttendanceStatus(val code: Int) { EXPECTED(0), CONFIRMED(1), CANCELLED(2), - ARRIVED(3); + ARRIVED(3), - /** Учитывается в заработке (только «пришёл»). */ - val countsTowardEarnings: Boolean get() = this == ARRIVED + /** + * Занятие оплачено — в YClients это плюсик на записи (`paid_full = 1`). + * + * Отдельно от [ARRIVED] намеренно: «пришёл» в YClients значит только то, + * что клиент явился, деньги за это ещё не получены (решение пользователя + * от 01.09.2026). Пока «пришёл» считался оплатой, неоплаченное занятие + * уже попадало в прибыль месяца. + */ + PAID(4); + + /** Учитывается в заработке — только оплаченное. */ + val countsTowardEarnings: Boolean get() = this == PAID + + /** Клиент был на занятии: пришёл, а может уже и заплатил. */ + val hasArrived: Boolean get() = this == ARRIVED || this == PAID /** Приоритет при слиянии нескольких визитов одного клиента за день. */ val mergePriority: Int get() = when (this) { + PAID -> 5 ARRIVED -> 4 CONFIRMED -> 3 CANCELLED -> 2 @@ -34,6 +49,7 @@ enum class AttendanceStatus(val code: Int) { 1 -> CONFIRMED 2 -> CANCELLED 3 -> ARRIVED + 4 -> PAID else -> EXPECTED } @@ -45,22 +61,40 @@ enum class AttendanceStatus(val code: Int) { else -> EXPECTED } - fun resolveFromRecord(attendance: Int, visitAttendance: Int?): AttendanceStatus { + /** + * Статус записи YClients: посещение плюс оплата. + * + * `paid_full = 1` перекрывает посещаемость: занятие оплачено, и деньги + * за него уже получены. Отменённую запись оплата не воскрешает — + * так бывает при списании абонемента за пропуск, и занятием это не + * становится. + */ + fun resolveFromRecord( + attendance: Int, + visitAttendance: Int?, + paidFull: Int? = null, + ): AttendanceStatus { val fromVisit = visitAttendance?.let { fromYClients(it) } val fromAttendance = fromYClients(attendance) - return if (fromVisit == null) { + val visited = if (fromVisit == null) { fromAttendance } else { maxOf(fromVisit, fromAttendance, compareBy { it.mergePriority }) } + return if (paidFull == 1 && visited != CANCELLED) PAID else visited } /** * Старый формат `name|true` и ручной офлайн-ввод (в т.ч. будущее редактирование архива). - * Даёт только EXPECTED/ARRIVED; для YClients и таймлайна — полный код статуса в строке. + * Даёт только EXPECTED/PAID; для YClients и таймлайна — полный код статуса в строке. + * + * `true` — это [PAID], а не [ARRIVED]: до разделения «пришёл» и + * «оплачено» (01.09.2026) галочка в старом формате значила + * «занятие состоялось и посчитано в деньгах». Отдай она теперь + * «пришёл», все давние записи молча выпали бы из заработка. */ fun fromBoolean(attended: Boolean): AttendanceStatus = - if (attended) ARRIVED else EXPECTED + if (attended) PAID else EXPECTED } } @@ -94,10 +128,18 @@ sealed interface Session { name.startsWith("–") || name.startsWith("−") } - /** Учитывается в заработке: только статус «пришёл», без отменённых записей. */ + /** Учитывается в заработке: только оплаченное, без отменённых записей. */ fun countsTowardEarnings(): Boolean = !isEffectivelyDeleted() && status.countsTowardEarnings + /** + * Занятие состоялось: клиент пришёл — заплатил он уже или ещё нет. + * + * Отдельно от [countsTowardEarnings]: «проведено 4 из 8» — про работу, а + * деньги идут за оплаченным (01.09.2026). + */ + fun countsAsAttended(): Boolean = !isEffectivelyDeleted() && status.hasArrived + @Immutable data class Student( val name: String, @@ -150,7 +192,17 @@ sealed interface Session { if (children.isNotEmpty()) { return children.any { it.status.countsTowardEarnings } } - return !isEffectivelyDeleted() && status.countsTowardEarnings + // Интенсив без детей заведён руками: признака оплаты у него нет и + // взяться ему неоткуда — YClients о такой записи не знает. Здесь + // «пришёл» и есть отметка человека о состоявшемся занятии. + return !isEffectivelyDeleted() && status.hasArrived + } + + override fun countsAsAttended(): Boolean { + if (children.isNotEmpty()) { + return children.any { it.status.hasArrived } + } + return !isEffectivelyDeleted() && status.hasArrived } } @@ -449,7 +501,9 @@ object SessionParser { private fun parseExtraStatusField(field: String): Pair? { val code = field.toIntOrNull() ?: return null val status = AttendanceStatus.fromCode(code) - return status.countsTowardEarnings to status + // `attended` — про «клиент был», а не про деньги: с разделением + // «пришёл» и «оплачено» это уже разные вещи (01.09.2026). + return status.hasArrived to status } private const val INTENSIVE_CHILD_SEP = ";;" diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt index 3d753a2..ae1af6d 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/DayDetailsDialog.kt @@ -950,7 +950,10 @@ private fun calculateStats( when (entry.status) { AttendanceStatus.EXPECTED -> if (!isIntensive) expected++ AttendanceStatus.CONFIRMED -> if (!isIntensive) confirmed++ - AttendanceStatus.ARRIVED -> { + // Пришёл — уже в счётчике состоявшихся; в деньги занятие попадает + // отдельно, по оплате (01.09.2026). + AttendanceStatus.ARRIVED -> if (!isIntensive) arrived++ + AttendanceStatus.PAID -> { if (!isIntensive) arrived++ money += if (!entry.isExtra) { rates.pricePerSession diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusPicker.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusPicker.kt index a496c95..a35a7e1 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusPicker.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusPicker.kt @@ -140,4 +140,5 @@ fun attendanceStatusLabel(status: AttendanceStatus): String = when (status) { AttendanceStatus.CONFIRMED -> stringResource(R.string.attendance_status_confirmed) AttendanceStatus.CANCELLED -> stringResource(R.string.attendance_status_cancelled) AttendanceStatus.ARRIVED -> stringResource(R.string.attendance_status_arrived) + AttendanceStatus.PAID -> stringResource(R.string.attendance_status_paid) } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusVisuals.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusVisuals.kt index e1a4693..f19c173 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusVisuals.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/AttendanceStatusVisuals.kt @@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.Remove import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -12,10 +13,16 @@ import androidx.compose.ui.graphics.vector.ImageVector import ru.greemlab.neiro.theme.neiroSemanticColors import ru.greemlab.neiro.ui.calendar.AttendanceStatus -/** Иконки и цвета статуса — как в [ScheduleSlotItem]. */ +/** + * Иконки и цвета статуса — как в [ScheduleSlotItem]. + * + * Значки повторяют YClients, чтобы человек, глядя в оба интерфейса, видел одно + * и то же: пришёл — восклицательный знак, оплачено — плюсик (01.09.2026). + */ object AttendanceStatusVisuals { fun icon(status: AttendanceStatus): ImageVector = when (status) { - AttendanceStatus.ARRIVED -> Icons.Rounded.Add + AttendanceStatus.PAID -> Icons.Rounded.Add + AttendanceStatus.ARRIVED -> Icons.Rounded.PriorityHigh AttendanceStatus.CONFIRMED -> Icons.Rounded.Check AttendanceStatus.CANCELLED -> Icons.Rounded.Remove AttendanceStatus.EXPECTED -> Icons.Rounded.History @@ -25,6 +32,10 @@ object AttendanceStatusVisuals { @ReadOnlyComposable fun nameColor(status: AttendanceStatus): Color = with(neiroSemanticColors) { when (status) { + // Пришёл, но ещё не заплатил — тот же «состоявшийся» зелёный, что и + // у оплаченного: занятие прошло, разница только в деньгах, и её + // показывает значок. + AttendanceStatus.PAID -> profit AttendanceStatus.ARRIVED -> profit AttendanceStatus.CONFIRMED -> expected AttendanceStatus.CANCELLED -> statusCancelled @@ -48,5 +59,6 @@ object AttendanceStatusVisuals { AttendanceStatus.CONFIRMED, AttendanceStatus.CANCELLED, AttendanceStatus.ARRIVED, + AttendanceStatus.PAID, ) } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/IntensiveDetailsDialog.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/IntensiveDetailsDialog.kt index e9df7d3..7c69ea0 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/IntensiveDetailsDialog.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/IntensiveDetailsDialog.kt @@ -83,9 +83,9 @@ private fun IntensiveDetailsCard( val semantic = neiroSemanticColors val timeLabel = formatIntensiveTimeLabel(time) - val arrivedCount = children.count { it.status == AttendanceStatus.ARRIVED } + val arrivedCount = children.count { it.status.hasArrived } val confirmedCount = children.count { - it.status == AttendanceStatus.CONFIRMED || it.status == AttendanceStatus.ARRIVED + it.status == AttendanceStatus.CONFIRMED || it.status.hasArrived } val amountLabel = if (amount > 0.0) formatRubles(amount) else null diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/ScheduleTime.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/ScheduleTime.kt index 1ac7d94..9686adf 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/ScheduleTime.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/daydetails/ScheduleTime.kt @@ -155,7 +155,8 @@ private fun entryToTimedAppointment(entry: TimelineEntry): TimedAppointment? { } private fun AttendanceStatus.isReplacementTop(): Boolean = - this == AttendanceStatus.ARRIVED || this == AttendanceStatus.CONFIRMED || this == AttendanceStatus.EXPECTED + this == AttendanceStatus.PAID || this == AttendanceStatus.ARRIVED || + this == AttendanceStatus.CONFIRMED || this == AttendanceStatus.EXPECTED fun computePositionedTimelineItems(appointments: List): List { val sorted = appointments.sortedWith(compareBy({ it.start }, { it.entry.name })) diff --git a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt index 975529a..c895830 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt @@ -28,7 +28,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -75,6 +77,12 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth +/** Отступ вокруг кнопки «Сегодня»: 16.dp до края экрана и столько же до сетки. */ +private val TODAY_BUTTON_MARGIN = 32.dp + +/** Прежний резерв под кнопку — нижняя граница, чтобы вёрстка не «подпрыгнула». */ +private val TODAY_BUTTON_MIN_RESERVE = 80.dp + /** * Все типы overlay, отображающихся поверх календаря. * Хранение в одном sealed класс гарантирует, что одновременно открыт @@ -833,6 +841,13 @@ fun CalendarScreenContent( var monthPickerVisible by rememberSaveable { mutableStateOf(false) } val pullRefreshState = rememberPullToRefreshState() val scrollableState = rememberScrollableState { 0f } + // Высота кнопки «Сегодня»: она висит поверх сетки, и место под неё внизу + // экрана резервируется по факту, а не константой. При системном шрифте + // 130–200 % кнопка перерастала прежние 80.dp и накрывала нижний ряд чисел — + // числа переставали нажиматься (Pixel 10, 01.09.2026). + var todayButtonHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + val todayButtonReserve = maxOf(TODAY_BUTTON_MIN_RESERVE, todayButtonHeight + TODAY_BUTTON_MARGIN) Surface( modifier = modifier.fillMaxSize(), @@ -932,7 +947,7 @@ fun CalendarScreenContent( } }, ) - Spacer(modifier = Modifier.navigationBarsPadding().height(80.dp)) + Spacer(modifier = Modifier.navigationBarsPadding().height(todayButtonReserve)) } MonthPickerOverlay( @@ -965,6 +980,14 @@ fun CalendarScreenContent( onClick = onTodayClick, modifier = Modifier .heightIn(min = 48.dp) + // Ноль приходит на скрытии — запоминаем последний + // настоящий размер, иначе сетка успевала растянуться + // ровно под возвращающуюся кнопку. + .onSizeChanged { size -> + if (size.height > 0) { + todayButtonHeight = with(density) { size.height.toDp() } + } + } .glassBorder(todayShape), containerColor = glassControlColor(), contentColor = if (glass) { diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsScreen.kt index 018fe93..813969e 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsScreen.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.rounded.Storage import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.AddCircle import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.WbTwilight import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,6 +38,8 @@ fun SessionNotificationSettingsScreen( viewModel: SessionNotificationSettingsViewModel = viewModel(), ) { val settings by viewModel.state.collectAsStateWithLifecycle() + val delivery by viewModel.delivery.collectAsStateWithLifecycle() + val deliveryChecking by viewModel.deliveryChecking.collectAsStateWithLifecycle() var reminderExpanded by remember { mutableStateOf(false) } Scaffold( @@ -64,6 +67,14 @@ fun SessionNotificationSettingsScreen( onCheckedChange = viewModel::setEnabled, ) + if (settings.isEnabled && delivery.isSupported && !delivery.isWorking) { + DeliveryProblemCard( + reason = delivery.lastError, + checking = deliveryChecking, + onRecheck = viewModel::recheckDelivery, + ) + } + if (settings.isEnabled) { SettingsSection(title = stringResource(R.string.notification_settings_changes)) { EventToggle( @@ -190,6 +201,69 @@ fun SessionNotificationSettingsScreen( } } +/** + * Канал мгновенных уведомлений не поднялся. + * + * Пуш приходит только на устройство с живым токеном Firebase. Не получив его, + * приложение молча жило на получасовом догоне — уведомления приходили, но с + * задержкой, и понять, почему, было неоткуда (IN2010, 01.09.2026). + */ +@Composable +private fun DeliveryProblemCard( + reason: String, + checking: Boolean, + onRecheck: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.notification_settings_delivery_broken_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.notification_settings_delivery_broken_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.9f), + ) + if (reason.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.notification_settings_delivery_reason, reason), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f), + ) + } + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onRecheck, enabled = !checking) { + Text( + stringResource( + if (checking) { + R.string.notification_settings_delivery_checking + } else { + R.string.notification_settings_delivery_recheck + }, + ), + ) + } + } + } +} + @Composable private fun MasterSwitchCard( checked: Boolean, diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsViewModel.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsViewModel.kt index 4d6d78e..2c90f94 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsViewModel.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/SessionNotificationSettingsViewModel.kt @@ -12,6 +12,8 @@ import ru.greemlab.neiro.notifications.ScheduledDigestKind import ru.greemlab.neiro.notifications.ScheduledNotificationTime import ru.greemlab.neiro.notifications.SessionNotificationCoordinator import ru.greemlab.neiro.notifications.SessionNotificationPreferences +import ru.greemlab.neiro.push.PushDeliveryDiagnostics +import ru.greemlab.neiro.push.PushRegistrar /** * Снимок настроек для экрана. Без значений по умолчанию намеренно: единственный @@ -44,6 +46,30 @@ class SessionNotificationSettingsViewModel(application: Application) : AndroidVi private val _state = MutableStateFlow(loadState()) val state: StateFlow = _state.asStateFlow() + /** Состояние канала мгновенных уведомлений — показывается отдельной плашкой. */ + val delivery: StateFlow = PushDeliveryDiagnostics.state + + private val _deliveryChecking = MutableStateFlow(false) + val deliveryChecking: StateFlow = _deliveryChecking.asStateFlow() + + /** + * «Проверить снова»: ещё одна попытка получить токен FCM и донести его до + * сервера. Firebase отказывает и по временным причинам (нет сети, спит + * сервис), поэтому ручная попытка имеет смысл — ждать получасового + * keepalive человеку незачем. + */ + fun recheckDelivery() { + if (_deliveryChecking.value) return + _deliveryChecking.value = true + viewModelScope.launch { + try { + PushRegistrar.onAppForegroundNow(getApplication()) + } finally { + _deliveryChecking.value = false + } + } + } + init { // Единый источник master-тумблера (SessionNotificationPreferences.isEnabledFlow) — // без этого переключение на экране AppSettings не отражалось бы здесь (P3). diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70381c8..062ed90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,6 +95,12 @@ Настроить типы уведомлений… Уведомления о занятиях Все события только по вашему расписанию после входа в YClients + Уведомления приходят с задержкой + Телефон не получил ключ мгновенной доставки от Google, поэтому изменения приходят проверкой раз в полчаса, а не сразу. Обычно помогает включённый интернет и свежие сервисы Google Play. + Причина: %1$s + Проверить снова + Проверяем… + Изменения в расписании Расписание @@ -116,6 +122,7 @@ Подтвердил Не пришёл Пришёл + Оплачено Архив не совпадает с данными YClients. Ручные правки в архиве могут отличаться. Показать отличия архива от YClients diff --git a/app/src/test/java/ru/greemlab/neiro/data/SalaryLedgerRulesTest.kt b/app/src/test/java/ru/greemlab/neiro/data/SalaryLedgerRulesTest.kt index e72f5d9..3539b0a 100644 --- a/app/src/test/java/ru/greemlab/neiro/data/SalaryLedgerRulesTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/data/SalaryLedgerRulesTest.kt @@ -223,7 +223,9 @@ class SalaryLedgerRulesTest { assertEquals(PriceOrigin.MANUAL, merged.origin) assertEquals(1450.0, merged.pricePerSession, 0.0) - assertEquals(6000.0, merged.tax, 0.0) + // Налог ручным не бывает: в записи всегда лежит сегодняшнее значение + // профиля, а расчёт месяца и вовсе читает профиль напрямую. + assertEquals(6500.0, merged.tax, 0.0) assertTrue(merged.note.startsWith("договорённость с центром")) // Факт всё равно обновился — сверять есть с чем. assertEquals(172_500.0, merged.factGross!!, 0.0) diff --git a/app/src/test/java/ru/greemlab/neiro/notifications/SessionChangeDetectorTest.kt b/app/src/test/java/ru/greemlab/neiro/notifications/SessionChangeDetectorTest.kt index e94157a..07a0df7 100644 --- a/app/src/test/java/ru/greemlab/neiro/notifications/SessionChangeDetectorTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/notifications/SessionChangeDetectorTest.kt @@ -62,6 +62,24 @@ class SessionChangeDetectorTest { assertEquals(SessionEventType.CLIENT_ARRIVED, events.first().type) } + @Test + fun `payment after arrival does not raise a second event`() { + // «Пришёл» и «оплатил» — два состояния одной записи; будить человека + // второй раз незачем (01.09.2026). + val before = listOf(session("Анна", "10:00", AttendanceStatus.ARRIVED)) + val after = listOf(session("Анна", "10:00", AttendanceStatus.PAID)) + assertEquals(0, SessionChangeDetector.detect(before, after).size) + } + + @Test + fun `arrival noticed together with payment still notifies once`() { + val before = listOf(session("Анна", "10:00", AttendanceStatus.CONFIRMED)) + val after = listOf(session("Анна", "10:00", AttendanceStatus.PAID)) + val events = SessionChangeDetector.detect(before, after) + assertEquals(1, events.size) + assertEquals(SessionEventType.CLIENT_ARRIVED, events.first().type) + } + private fun session( name: String, start: String, diff --git a/app/src/test/java/ru/greemlab/neiro/push/PushEventCalendarApplierTest.kt b/app/src/test/java/ru/greemlab/neiro/push/PushEventCalendarApplierTest.kt index 865a287..fbaff50 100644 --- a/app/src/test/java/ru/greemlab/neiro/push/PushEventCalendarApplierTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/push/PushEventCalendarApplierTest.kt @@ -38,6 +38,37 @@ class PushEventCalendarApplierTest { assertEquals(AttendanceStatus.ARRIVED, statusOf(result, date, 0)) } + @Test + fun `приход не сбрасывает уже отмеченную оплату`() { + // Сервер про `paid_full` не знает и шлёт «пришёл» и на оплаченное + // занятие. Понижение статуса вернуло бы деньги дня в ожидаемые до + // следующего полного синка (01.09.2026). + val paid = SessionFormat.serializeStudentExtended( + name = "Иванова Мария", + status = AttendanceStatus.PAID, + time = "15:00-15:50", + ) + val dayData = mapOf(date to listOf(paid)) + + val result = applyOne(dayData, event(type = "CLIENT_ARRIVED")) + + assertEquals(AttendanceStatus.PAID, statusOf(result, date, 0)) + } + + @Test + fun `отмена перебивает даже оплату`() { + val paid = SessionFormat.serializeStudentExtended( + name = "Иванова Мария", + status = AttendanceStatus.PAID, + time = "15:00-15:50", + ) + val dayData = mapOf(date to listOf(paid)) + + val result = applyOne(dayData, event(type = "CANCELLED")) + + assertEquals(AttendanceStatus.CANCELLED, statusOf(result, date, 0)) + } + @Test fun `отмена меняет статус на CANCELLED и запись остаётся`() { val dayData = mapOf(date to listOf(student("Иванова Мария", "15:00-15:50"))) diff --git a/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt b/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt index fe05507..06b4dee 100644 --- a/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/sync/DailySessionRateTest.kt @@ -19,7 +19,7 @@ class DailySessionRateTest { private val today = LocalDate.of(2026, 8, 20) - private fun students(count: Int): List = List(count) { "Ученик $it|3" } + private fun students(count: Int): List = List(count) { "Ученик $it|4" } @Test fun `price comes from the last clean day`() { @@ -48,7 +48,7 @@ class DailySessionRateTest { cleanDay to DayFact(salary = 9000.0, servicesCount = 6, groupServicesCount = 0), ) val dayData = mapOf( - mixedDay to students(5) + "__DIAGNOSTICS__:2250|Аня|3", + mixedDay to students(5) + "__DIAGNOSTICS__:2250|Аня|4", cleanDay to students(6), ) diff --git a/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt b/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt index 5323525..ebc99bc 100644 --- a/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/sync/YClientsCalendarSyncTest.kt @@ -265,6 +265,50 @@ class YClientsCalendarSyncTest { assertEquals(listOf(manual), retained) } + @Test + fun `full API answer drops local entries without a pair`() { + val student = SessionFormat.serializeStudentExtended( + name = "Иванов", + status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + time = "10:00-10:50", + ) + val intensive = SessionFormat.serializeIntensive( + price = "5600", + name = "Интенсив", + status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + time = "19:00-19:50", + amountFixed = true, + ) + val survived = YClientsCalendarSync.survivingLocalEntries( + unmatched = listOf(student, intensive), + dropUnmatched = true, + ) + assertEquals(listOf(intensive), survived) + } + + @Test + fun `incremental answer keeps the rest of the day`() { + // Догон по changed_after отдаёт одну изменившуюся запись; остальные + // занятия дня в ответе не участвуют и удалению не подлежат. + val untouched = listOf( + SessionFormat.serializeStudentExtended( + name = "Иванов", + status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.ARRIVED, + time = "10:00-10:50", + ), + SessionFormat.serializeStudentExtended( + name = "Петров", + status = ru.greemlab.neiro.ui.calendar.AttendanceStatus.EXPECTED, + time = "11:00-11:50", + ), + ) + val survived = YClientsCalendarSync.survivingLocalEntries( + unmatched = untouched, + dropUnmatched = false, + ) + assertEquals(untouched, survived) + } + private fun fakeRecord() = ru.greemlab.neiro.data.network.RecordData( id = 1L, companyId = 1, diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculatorTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculatorTest.kt index 9ab8a01..c301a40 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculatorTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/CalendarStatsCalculatorTest.kt @@ -83,14 +83,16 @@ class CalendarStatsCalculatorTest { @Test fun `month stats use per-child rate for api intensives and fixed amount for manual`() { + // Дети интенсива из API оплачены: деньги считаются по оплате, а не по + // приходу (01.09.2026). val apiIntensive = SessionFormat.serializeIntensive( price = "", name = "Интенсив", - status = AttendanceStatus.ARRIVED, + status = AttendanceStatus.PAID, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), ) val manual = SessionFormat.serializeIntensive( diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt index 682ffa8..5f391c5 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt @@ -41,7 +41,7 @@ class DaySummaryStatsTest { status = AttendanceStatus.EXPECTED, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), Session.IntensiveChild("Маша", AttendanceStatus.EXPECTED), ), ) @@ -63,11 +63,11 @@ class DaySummaryStatsTest { status = AttendanceStatus.ARRIVED, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), ), ) val stats = computeDayStats( - listOf(intensive, "Дима|3|18:00-18:50"), + listOf(intensive, "Дима|4|18:00-18:50"), rates = EarningsContext( pricePerSession = 1000.0, pricePerIntensiveChild = 800.0, @@ -110,7 +110,7 @@ class DaySummaryStatsTest { @Test fun `cancelled student does not count as lesson`() { val stats = computeDayStats( - listOf("Иванов|2", "Петров|3"), + listOf("Иванов|2", "Петров|4"), rates = EarningsContext(pricePerSession = 1000.0), ) assertEquals(1, stats.totalLessons) @@ -129,6 +129,20 @@ class DaySummaryStatsTest { assertEquals(2000.0, stats.earned, 0.0) } + @Test + fun `arrived but unpaid lesson is conducted, not earned`() { + // «Пришёл» в YClients — это восклицательный знак на записи, а не + // оплата: занятие состоялось, но деньги ещё впереди (01.09.2026). + val stats = computeDayStats( + listOf("Шкурупий|3", "Савастьянов|4"), + rates = EarningsContext(pricePerSession = 1500.0), + ) + assertEquals(2, stats.totalLessons) + assertEquals(2, stats.attendedLessons) + assertEquals(1500.0, stats.earned, 0.0) + assertEquals(1500.0, stats.expected, 0.0) + } + @Test fun `computeDayStats counts confirmed and pending lessons`() { val stats = computeDayStats( @@ -153,12 +167,12 @@ class DaySummaryStatsTest { status = AttendanceStatus.EXPECTED, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), Session.IntensiveChild("Маша", AttendanceStatus.EXPECTED), ), ) val stats = computeDayStats( - listOf(intensive, "Иванов|3", "__DIAGNOSTICS__:500|Аня|true"), + listOf(intensive, "Иванов|4", "__DIAGNOSTICS__:500|Аня|true"), rates = EarningsContext( pricePerSession = 1000.0, pricePerIntensiveChild = 800.0, @@ -223,7 +237,7 @@ class DaySummaryStatsTest { @Test fun `day fact marks earnings as coming from api`() { val stats = computeDayStats( - listOf("Иванов|3"), + listOf("Иванов|4"), rates = EarningsContext(pricePerSession = 1000.0), dayFact = 1500.0, ) @@ -236,7 +250,7 @@ class DaySummaryStatsTest { @Test fun `cancelled student stays in scheduled and potential earnings`() { val stats = computeDayStats( - listOf("Иванов|2", "Петров|3"), + listOf("Иванов|2", "Петров|4"), rates = EarningsContext(pricePerSession = 1000.0), ) assertEquals(1, stats.lessons.planned) @@ -260,7 +274,7 @@ class DaySummaryStatsTest { time = "18:00-18:50", children = listOf( Session.IntensiveChild("Дима", AttendanceStatus.CANCELLED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), ) val stats = computeDayStats( @@ -286,7 +300,7 @@ class DaySummaryStatsTest { time = "19:00-19:50", children = listOf( Session.IntensiveChild("Дима", AttendanceStatus.CANCELLED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), amountFixed = true, ) @@ -310,8 +324,8 @@ class DaySummaryStatsTest { status = AttendanceStatus.ARRIVED, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), ) val stats = computeDayStats( diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtilsTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtilsTest.kt index d394f0d..fc90787 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtilsTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/IntensiveSessionUtilsTest.kt @@ -14,8 +14,8 @@ class IntensiveSessionUtilsTest { status = AttendanceStatus.ARRIVED, amountFixed = true, children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Дима", AttendanceStatus.PAID), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), ) assertEquals(5600.0, session.totalAmount(1400.0, onlyArrived = true), 0.0) @@ -31,7 +31,8 @@ class IntensiveSessionUtilsTest { status = AttendanceStatus.EXPECTED, amountFixed = false, children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + // Деньги интенсива считаются по оплаченным детям (01.09.2026). + Session.IntensiveChild("Дима", AttendanceStatus.PAID), Session.IntensiveChild("Маша", AttendanceStatus.EXPECTED), Session.IntensiveChild("Петя", AttendanceStatus.CANCELLED), ), diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolverTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolverTest.kt index 39d43da..3133fc2 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolverTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/MonthRatesResolverTest.kt @@ -187,6 +187,29 @@ class MonthRatesResolverTest { assertEquals(1400.0, resolved.rates.pricePerSession, 0.0) } + @Test + fun `corrected tax reaches a month with an old snapshot`() { + // Человек однажды ввёл в настройках не ту сумму налога и исправил её. + // Прошлые месяцы обязаны показать исправленную: задать налог отдельно + // на месяц негде, и слепок в записи — не решение человека (01.09.2026). + val withWrongTax = MonthEntry( + staffId = 1L, + year = 2026, + month = 5, + pricePerSession = 1400.0, + tax = 62_500.0, + factGross = 79_300.0, + factSessions = 51, + ) + val resolved = resolveMonthRates( + month = YearMonth.of(2026, 5), + entry = withWrongTax, + profile = profile, + today = today, + ) + assertEquals(6500.0, resolved.rates.monthlyTaxAmount, 0.0) + } + @Test fun `entry prices win over profile when set`() { val withPrices = MonthEntry( @@ -209,7 +232,9 @@ class MonthRatesResolverTest { assertEquals(1250.0, resolved.rates.pricePerSession, 0.0) assertEquals(2000.0, resolved.rates.pricePerDiagnostics, 0.0) assertEquals(1250.0, resolved.rates.pricePerIntensiveChild, 0.0) - assertEquals(6000.0, resolved.rates.monthlyTaxAmount, 0.0) + // Налог — исключение: он живёт только в профиле, слепок 6000 в записи + // расчётом не читается (01.09.2026). + assertEquals(6500.0, resolved.rates.monthlyTaxAmount, 0.0) } @Test @@ -346,10 +371,10 @@ class MonthRatesResolverTest { ) val dayData = mapOf( LocalDate.of(2026, 6, 19) to listOf( - "Иванов|3", - "Петров|3", + "Иванов|4", + "Петров|4", "Сидоров|0", // ещё не пришёл — в услуги месяца не идёт - "__DIAGNOSTICS__:2250|Аня|3", + "__DIAGNOSTICS__:2250|Аня|4", intensive, // Ученик на слоте интенсива — та же услуга, второй раз не считаем. "Дима|3|18:00-18:50", @@ -388,13 +413,13 @@ class MonthRatesResolverTest { ) val dayData = mapOf( LocalDate.of(2026, 6, 19) to listOf( - "Иванов|3", - "__DIAGNOSTICS__:2250|Аня|3", + "Иванов|4", + "__DIAGNOSTICS__:2250|Аня|4", apiIntensive, manualIntensive, ), // Другой месяц — в счёт не идёт. - LocalDate.of(2026, 5, 19) to listOf("__DIAGNOSTICS__:2250|Оля|3"), + LocalDate.of(2026, 5, 19) to listOf("__DIAGNOSTICS__:2250|Оля|4"), ) val facts = collectMonthLocalFacts( diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/ProfileYearStatsTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/ProfileYearStatsTest.kt index 71debb7..ec472e5 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/ProfileYearStatsTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/ProfileYearStatsTest.kt @@ -109,11 +109,12 @@ class ProfileYearStatsTest { val intensive = SessionFormat.serializeIntensive( price = "", name = "Интенсив", - status = AttendanceStatus.ARRIVED, + status = AttendanceStatus.PAID, time = "18:00-18:50", children = listOf( - Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), - Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + // Деньги интенсива идут за оплаченными детьми (01.09.2026). + Session.IntensiveChild("Дима", AttendanceStatus.PAID), + Session.IntensiveChild("Маша", AttendanceStatus.PAID), ), ) val dayData = mapOf( @@ -144,11 +145,13 @@ class ProfileYearStatsTest { } @Test - fun `year tax follows month history, not the current profile`() { - // Налог месяца фиксируется в истории при первой записи и после правки в - // профиле не пересчитывается. Пока год считался по сегодняшнему налогу - // профиля, «налог за год» и сумма месяцев рядом описывали разные - // вселенные (аудит 14.08.26, U2). + fun `year tax follows the profile, not the number frozen in history`() { + // Налог задаётся только в профиле, и правка в настройках обязана + // дойти до всех месяцев: слепок в записи месяца — след, а не + // источник. Пока расчёт читал слепок, однажды ошибочно введённая + // сумма оставалась в истории навсегда (01.09.2026). Согласованность + // «год = сумма месяцев» (аудит 14.08.26, U2) при этом сохраняется: + // и год, и каждый месяц берут одно и то же число из профиля. // pricePerSession обязателен: без своей цены резолвер месяца до налога // не доходит и целиком уходит в профиль (MonthRatesResolver). val ledger = SalaryLedger.Empty @@ -181,9 +184,9 @@ class ProfileYearStatsTest { staffId = staffId, today = LocalDate.of(2026, 7, 30), ) - // Январь и февраль — по 4000 из истории, остальные десять — по 6500 - // из профиля: своей записи у них нет. - assertEquals(4000.0 * 2 + 6500.0 * 10, stats.totalTaxAmount, 0.0) + // Все двенадцать месяцев — по 6500 из профиля, включая январь и + // февраль со старым слепком 4000 в записи. + assertEquals(6500.0 * 12, stats.totalTaxAmount, 0.0) } @Test @@ -255,7 +258,7 @@ class ProfileYearStatsTest { @Test fun `past month with fact uses price from fact and not from profile`() { val dayData = mapOf( - LocalDate.of(2025, 4, 10) to listOf("Иванов|3", "Петров|3"), + LocalDate.of(2025, 4, 10) to listOf("Иванов|4", "Петров|4"), ) val ledger = SalaryLedger.Empty.withMonth( MonthEntry( @@ -284,7 +287,7 @@ class ProfileYearStatsTest { // В календаре два занятия, в YClients — 102 услуги и 127 500 ₽. // Показываем начисление: перемножением вышло бы 2500 вместо 127 500. val dayData = mapOf( - LocalDate.of(2025, 4, 10) to listOf("Иванов|3", "Петров|3"), + LocalDate.of(2025, 4, 10) to listOf("Иванов|4", "Петров|4"), ) val ledger = SalaryLedger.Empty.withMonth( MonthEntry( @@ -316,7 +319,7 @@ class ProfileYearStatsTest { // Февраль–май 2026 в жизни: YClients считал по 1500 из-за старой схемы // percent, на руки было 1400 — человек ставит свою цену, и она главнее. val dayData = mapOf( - LocalDate.of(2025, 4, 10) to listOf("Иванов|3", "Петров|3"), + LocalDate.of(2025, 4, 10) to listOf("Иванов|4", "Петров|4"), ) val ledger = SalaryLedger.Empty.withMonth( MonthEntry( @@ -382,7 +385,7 @@ class ProfileYearStatsTest { amountFixed = true, ) val dayData = mapOf( - LocalDate.of(2025, 4, 10) to listOf("Иванов|3", manualIntensive), + LocalDate.of(2025, 4, 10) to listOf("Иванов|4", manualIntensive), ) val ledger = SalaryLedger.Empty.withMonth( MonthEntry( @@ -409,7 +412,7 @@ class ProfileYearStatsTest { fun `current month is not touched by payroll`() { // Текущий месяц считается по профилю: начисление за него ещё не закрыто. val dayData = mapOf( - LocalDate.of(2026, 7, 10) to listOf("Иванов|3", "Петров|3"), + LocalDate.of(2026, 7, 10) to listOf("Иванов|4", "Петров|4"), ) val ledger = SalaryLedger.Empty.withMonth( MonthEntry( @@ -452,7 +455,7 @@ class ProfileYearStatsTest { ) val stats = computeProfileYearStats( year = 2025, - dayData = mapOf(LocalDate.of(2025, 4, 10) to listOf("Иванов|3")), + dayData = mapOf(LocalDate.of(2025, 4, 10) to listOf("Иванов|4")), profileRates = EarningsContext(pricePerSession = 1400.0, monthlyTaxAmount = 6500.0), ledger = ledger, staffId = staffId, diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/SessionParserTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/SessionParserTest.kt index 9aa66de..ccd7d9c 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/SessionParserTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/SessionParserTest.kt @@ -192,6 +192,42 @@ class SessionParserTest { assertEquals(AttendanceStatus.ARRIVED, status) } + @Test + fun `paid record becomes PAID and counts toward earnings`() { + // Плюсик в YClients (`paid_full = 1`) — единственное, что заводит + // занятие в деньги (01.09.2026). + val status = AttendanceStatus.resolveFromRecord( + attendance = 1, + visitAttendance = 1, + paidFull = 1, + ) + assertEquals(AttendanceStatus.PAID, status) + assertTrue(status.countsTowardEarnings) + } + + @Test + fun `arrived without payment stays ARRIVED and earns nothing`() { + val status = AttendanceStatus.resolveFromRecord( + attendance = 1, + visitAttendance = 1, + paidFull = 0, + ) + assertEquals(AttendanceStatus.ARRIVED, status) + assertFalse(status.countsTowardEarnings) + assertTrue(status.hasArrived) + } + + @Test + fun `payment does not resurrect a cancelled record`() { + // Списание абонемента за пропуск: деньги прошли, а занятия не было. + val status = AttendanceStatus.resolveFromRecord( + attendance = -1, + visitAttendance = -1, + paidFull = 1, + ) + assertEquals(AttendanceStatus.CANCELLED, status) + } + @Test fun `resolveFromRecord prefers cancelled over waiting`() { val status = AttendanceStatus.resolveFromRecord( diff --git a/docs/neiro-push/API.md b/docs/neiro-push/API.md index 729f0c4..46ca75c 100644 --- a/docs/neiro-push/API.md +++ b/docs/neiro-push/API.md @@ -90,6 +90,12 @@ Secrets, то есть вне Pi. Админским ключом можно у вернула бы `token_invalid` и снесла бы вместе с ним рабочий `device_token`. Токен доедет позже через `POST /v1/devices/fcm`. +Пустой `fcm_token` при входе рабочий токен не затирает (01.09.2026): вход с +телефона, где Firebase не успел ответить, иначе отключал бы пуши на устройстве, +где они работали. Само приложение о таком состоянии больше не молчит — экран +«Уведомления о занятиях» показывает, что мгновенная доставка не поднялась, с +причиной от Firebase и кнопкой «Проверить снова». + Вход повторно с того же `device_id` — нормальная операция: старый `device_token` этого устройства отзывается, выдаётся новый, курсор событий (`last_ack_event_id`) сохраняется. Событий за время без связи телефон не diff --git a/docs/pricing-from-api/API-HOWTO.md b/docs/pricing-from-api/API-HOWTO.md index 6a51f04..f5ca0a1 100644 --- a/docs/pricing-from-api/API-HOWTO.md +++ b/docs/pricing-from-api/API-HOWTO.md @@ -286,6 +286,14 @@ print(get(f"/company/{COMPANY}/salary/period/staff/daily/{STAFF}/", Статус посещения — `attendance` в записи: `1` пришёл, `0` ожидание, `-1` неявка. **В ЗП идёт только `1`.** +Оплата — отдельное поле `paid_full` (`1` — оплачено); в интерфейсе YClients это +плюсик на записи, тогда как `attendance = 1` рисуется восклицательным знаком. +Приход оплатой не является: 01.09.2026 в дне было четыре записи с +`attendance = 1`, из них оплачены три. Приложение с этого дня различает их +статусами `ARRIVED` и `PAID`: «проведено» считается по приходу, деньги — по +оплате (`SessionParser.AttendanceStatus`). Отменённую запись оплата не +воскрешает — так выглядит списание абонемента за пропуск. + --- ## 6. Ограничения, на которые наткнулись diff --git a/gradle.properties b/gradle.properties index a539222..a83f279 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,6 @@ android.enableJetifier=false android.enableR8.fullMode=true android.nonTransitiveRClass=true android.nonFinalResIds=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/neiro-push/app/database.py b/neiro-push/app/database.py index 9d6ac0f..b1102f5 100644 --- a/neiro-push/app/database.py +++ b/neiro-push/app/database.py @@ -328,7 +328,12 @@ def upsert_device( """Повторный вход с того же `device_id` — обычное дело: старый хэш затирается новым (прежний `device_token` с этого момента мёртв), отзыв снимается, а `last_ack_event_id` не трогается — иначе телефон потерял бы - события, накопившиеся за время без связи (API.md § auth/login).""" + события, накопившиеся за время без связи (API.md § auth/login). + + Пустой `fcm_token` рабочий не затирает: приложение шлёт пустую строку, + когда Firebase не успел отдать токен ко входу, и такой вход отключал бы + пуши на устройстве, где они работали. Настоящий токен приезжает следом + отдельным запросом (`PUT /v1/devices/fcm-token`).""" now = utc_now_iso() with self.connect() as conn: conn.execute( @@ -341,7 +346,10 @@ def upsert_device( account_id = excluded.account_id, token_hash = excluded.token_hash, revoked_at = NULL, - fcm_token = excluded.fcm_token, + fcm_token = CASE + WHEN excluded.fcm_token != '' THEN excluded.fcm_token + ELSE devices.fcm_token + END, label = excluded.label, app_version = excluded.app_version, last_seen_at = excluded.last_seen_at, diff --git a/neiro-push/tests/test_database.py b/neiro-push/tests/test_database.py index dcc9883..032c41e 100644 --- a/neiro-push/tests/test_database.py +++ b/neiro-push/tests/test_database.py @@ -185,6 +185,25 @@ def test_repeat_login_replaces_token_and_keeps_cursor(tmp_path: Path) -> None: assert row["last_ack_event_id"] == 42 +def test_login_without_fcm_token_keeps_the_working_one(tmp_path: Path) -> None: + """Вход с пустым `fcm_token` не отключает пуши. + + Приложение шлёт пустую строку, когда Firebase не успел отдать токен ко + входу. Пока такой вход затирал рабочий токен, устройство переставало + получать пуши и жило на одном догоне (01.09.2026).""" + db = Database(str(tmp_path / "events.db")) + account_id = db.upsert_account(1, 10, "ut") + db.upsert_device(account_id, "dev1", "hash-old", "fcm1", None, None) + + db.upsert_device(account_id, "dev1", "hash-new", "", None, None) + + with db.connect() as conn: + row = conn.execute( + "SELECT fcm_token FROM devices WHERE device_id = 'dev1'" + ).fetchone() + assert row["fcm_token"] == "fcm1" + + def test_successful_login_clears_reauth_required(tmp_path: Path) -> None: db = Database(str(tmp_path / "events.db")) account_id = db.upsert_account(1, 10, "ut")