60 Commits
v2.0 ... v2.6.3

Author SHA1 Message Date
ddc9cf2584 README: меню в возможностях и новые классы в структуре 2026-09-29 05:12:56 +00:00
a8ec1ce654 Страница: меню-окошко в шапке, свой экран «О приложении» 2026-09-29 05:12:55 +00:00
187de60f5c Манифест: тема без ActionBar, окно «О приложении» 2026-09-29 05:12:53 +00:00
c7a7726eb7 MainActivity: только главный экран, навигация уехала в страницу 2026-09-29 05:12:51 +00:00
ccbde3a9dd AboutActivity: окно «О приложении» на общей странице 2026-09-29 05:12:50 +00:00
828106a6fc BusBridge: JS-мост меню, стрелки назад и версии сборки 2026-09-29 05:12:49 +00:00
e7df71281d Webs: общий WebView для всех экранов 2026-09-29 05:12:31 +00:00
dbad63bc77 Строки: заголовки окон приложения 2026-09-29 05:11:02 +00:00
af1f215b8a Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:11:01 +00:00
38adfdb34b Строки: заголовки окон приложения 2026-09-29 05:10:48 +00:00
5e15e685fa Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:10:46 +00:00
baabcd31f8 README: рассылка через топики, топик не требует создания в консоли 2026-09-28 12:24:10 +00:00
83fc10878b README: раздел про RuStore Push и структура классов пуша 2026-09-28 12:19:43 +00:00
576f794ff7 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:42 +00:00
bd1db2a468 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:41 +00:00
905214b49e Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:40 +00:00
74d3f395ce Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:39 +00:00
034e7e1e27 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:38 +00:00
09bffa78a3 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:37 +00:00
f0088b08b6 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:36 +00:00
91a43a37b4 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:35 +00:00
4a2aa59c77 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:33 +00:00
3aab0b826b Версия 2.6.1
Сборка соответствует опубликованному коду: тег v2.6 остался на более
раннем коммите, где карточки ближайших рейсов ещё зависели от выбора
дня. Исправление (карточки всегда на сегодня) было закоммичено позже,
и тег на него не указывал. Тег v2.6.1 поставлен на актуальный код.

Само поведение не менялось — это та же исправленная версия, что была
выложена как 2.6, но теперь исходники, тег и APK соответствуют
друг другу.
2026-09-28 10:53:02 +00:00
6e03bc997c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:16 +00:00
f04bb1843c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:15 +00:00
ed621b7ce2 Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:38 +00:00
0e9150b5ce Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:37 +00:00
6f591c10d5 Убрана надпись «Мурманская область», экран не гаснет
В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе переключатель дней в полном расписании
(Будни / Суббота / Воскресенье) из предыдущих коммитов.
2026-09-28 10:12:35 +00:00
a15dccf3c2 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:39 +00:00
f82fe1a524 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:37 +00:00
0b4852c582 Переключатель дней в полном расписании
В разделе «Полное расписание» появились кнопки «Будни», «Суббота»,
«Воскресенье». По умолчанию подсвечивается реальный день недели
по часовому поясу МСК, далее таблица переключается вручную.

Единый массив SCHEDULE заменён на SCHEDULES с расписанием по типам
дней: 13 рейсов в будни, 10 в субботу, 6 в воскресенье.
Карточки ближайших рейсов переключаются вместе с таблицей.

Важно: по субботе и воскресенью в репозитории хранились только
отправления из Полярных Зорь и прибытия в Зашеек (app/src/main/java/
ru/ua1zbe/bus127/MainActivity.kt). Первая и четвёртая колонки
восстановлены по правилам будних дней — кол0 = кол1 − 50 мин,
кол3 = кол2 + 20 мин — и требуют сверки с официальным расписанием.
Также исправлен перепуск субботы и воскресенья: Date#getDay()
возвращает 0 для воскресенья, а не для субботы.
2026-09-28 08:05:36 +00:00
37ff82a6ef Релиз 2.5: versionCode 7, versionName 2.5 (надпись в шапке, установлено на телефон) 2026-09-28 07:57:13 +00:00
5d1236dd87 Надпись в шапке: только «Мурманская область»
Убрано «· регулярный маршрут» — надпись в шапке теперь просто «Мурманская область».
2026-09-28 07:53:27 +00:00
4642ca0115 Надпись в шапке: только «Мурманская область»
Убрано «· регулярный маршрут» — надпись в шапке теперь просто «Мурманская область».
2026-09-28 07:53:25 +00:00
0a3d7baf82 Релиз 2.4: versionCode 6, versionName 2.4 (прибытие в Полярные Зори из Кандалакши) 2026-09-28 06:52:40 +00:00
885a96dced Карточка Кандалакши: добавлено прибытие в Полярные Зори
Раньше в карточке было только «прибытие Зашеек», хотя рейс идёт
через Полярные Зори и время прибытия туда есть в таблице (колонка 1).
Теперь показываются оба прибытия: Полярные Зори, затем Зашеек.
Время берётся из таблицы, ничего не рассчитывается.
2026-09-28 06:51:39 +00:00
f437fb8c5a Карточка Кандалакши: добавлено прибытие в Полярные Зори
Раньше в карточке было только «прибытие Зашеек», хотя рейс идёт
через Полярные Зори и время прибытия туда есть в таблице (колонка 1).
Теперь показываются оба прибытия: Полярные Зори, затем Зашеек.
Время берётся из таблицы, ничего не рассчитывается.
2026-09-28 06:51:38 +00:00
366d0dd454 Релиз 2.3: versionCode 5, versionName 2.3 (расчёт прибытия в Кандалакшу) 2026-09-28 06:45:40 +00:00
80b1b2b0cc Считается прибытие в Кандалакшу
Для направления Полярные Зори → Кандалакша время прибытия в таблице
расписания отсутствует, поэтому оно теперь рассчитывается: отправление
из Полярных Зорь плюс 50 минут. Время в пути взято из самой таблицы —
в 12 строках из 13 Кандалакша → Полярные Зори занимает ровно 50 минут.

Заодно исправлена ошибка из v2.2: в карточке Зашейка колонка 3 подписана
как «прибытие Кандалакша», хотя это время в Полярных Зорях (20–30 минут
после прибытия в Зашеек, а не час до Кандалакши). Теперь в карточке
Зашейка показываются оба прибытия: Полярные Зори, затем Кандалакша.

Модель направлений переведена на список прибытий arrs, где время берётся
либо из колонки таблицы (col), либо считается как offset от отправления.
2026-09-28 06:44:57 +00:00
269a651325 Считается прибытие в Кандалакшу
Для направления Полярные Зори → Кандалакша время прибытия в таблице
расписания отсутствует, поэтому оно теперь рассчитывается: отправление
из Полярных Зорь плюс 50 минут. Время в пути взято из самой таблицы —
в 12 строках из 13 Кандалакша → Полярные Зори занимает ровно 50 минут.

Заодно исправлена ошибка из v2.2: в карточке Зашейка колонка 3 подписана
как «прибытие Кандалакша», хотя это время в Полярных Зорях (20–30 минут
после прибытия в Зашеек, а не час до Кандалакши). Теперь в карточке
Зашейка показываются оба прибытия: Полярные Зори, затем Кандалакша.

Модель направлений переведена на список прибытий arrs, где время берётся
либо из колонки таблицы (col), либо считается как offset от отправления.
2026-09-28 06:44:56 +00:00
6386a3420e Релиз 2.2: versionCode 4, versionName 2.2 (карточки Кандалакша/Зашеек, 5 рейсов) 2026-09-28 06:36:40 +00:00
eecb0ed95b Остановки Кандалакша и Зашеек: одна карточка направления, 5 ближайших рейсов
Раньше для крайних остановок показывались две карточки, хотя все рейсы
отправляются в одно время (Кандалакша — по отправлению, Зашеек — по
отправлению) и различаются только конечной остановкой. Карточки объединены:
- Кандалакша: «Полярные Зори, Зашеек», отправление 05:20 → Зашеек 06:30;
- Зашеек: «Полярные Зори, Кандалакша», отправление 06:30 → Кандалакша 06:50.

Число рейсов в карточке увеличено с 3 до 5 (TRIPS_SHOWN). Одна карточка
занимает всю ширину. Время прибытия подписано остановкой, так как в
объединённой карточке время прибытия относится к конечной остановке.
2026-09-28 06:34:53 +00:00
99bb3b2981 Остановки Кандалакша и Зашеек: одна карточка направления, 5 ближайших рейсов
Раньше для крайних остановок показывались две карточки, хотя все рейсы
отправляются в одно время (Кандалакша — по отправлению, Зашеек — по
отправлению) и различаются только конечной остановкой. Карточки объединены:
- Кандалакша: «Полярные Зори, Зашеек», отправление 05:20 → Зашеек 06:30;
- Зашеек: «Полярные Зори, Кандалакша», отправление 06:30 → Кандалакша 06:50.

Число рейсов в карточке увеличено с 3 до 5 (TRIPS_SHOWN). Одна карточка
занимает всю ширину. Время прибытия подписано остановкой, так как в
объединённой карточке время прибытия относится к конечной остановке.
2026-09-28 06:34:51 +00:00
5fb1462af6 Сборка: удалён дубликат ресурсов из apk-webview/res/ (перенесены в app/src/main/res/) 2026-09-28 05:18:27 +00:00
e90ea69d44 Сборка: удалён дубликат ресурсов из apk-webview/res/ (перенесены в app/src/main/res/) 2026-09-28 05:18:26 +00:00
297d61b065 Сборка: удалён дубликат ресурсов из apk-webview/res/ (перенесены в app/src/main/res/) 2026-09-28 05:18:25 +00:00
af8107b34c Сборка: удалён дубликат ресурсов из apk-webview/res/ (перенесены в app/src/main/res/) 2026-09-28 05:18:24 +00:00
0f72977cb9 Релиз 2.1: versionCode 3, versionName 2.1 (исправление часового пояса) 2026-09-28 05:18:23 +00:00
886849b949 Сборка: иконки перенесены в app/src/main/res
Ресурсы лежали в apk-webview/res/, откуда плагин Android их не читает,
из-за чего main не собирался: AAPT error: resource mipmap/ic_launcher not found.
Перенесены в стандартный путь app/src/main/res/.
2026-09-28 05:18:22 +00:00
5f8a797a58 Сборка: иконки перенесены в app/src/main/res
Ресурсы лежали в apk-webview/res/, откуда плагин Android их не читает,
из-за чего main не собирался: AAPT error: resource mipmap/ic_launcher not found.
Перенесены в стандартный путь app/src/main/res/.
2026-09-28 05:18:21 +00:00
3859c22086 Сборка: иконки перенесены в app/src/main/res
Ресурсы лежали в apk-webview/res/, откуда плагин Android их не читает,
из-за чего main не собирался: AAPT error: resource mipmap/ic_launcher not found.
Перенесены в стандартный путь app/src/main/res/.
2026-09-28 05:18:20 +00:00
0b9af4b067 Сборка: иконки перенесены в app/src/main/res
Ресурсы лежали в apk-webview/res/, откуда плагин Android их не читает,
из-за чего main не собирался: AAPT error: resource mipmap/ic_launcher not found.
Перенесены в стандартный путь app/src/main/res/.
2026-09-28 05:18:19 +00:00
20ae4b0606 Часовой пояс: время по МСК независимо от часового пояса устройства
Было: nowMs() прибавляла 3 часа к epoch, а getHours()/getDay()
читали результат в местном поясе телефона. На устройстве в МСК
приложение показывало время на 3 часа вперёд.

Стало: Europe/Moscow через Intl.DateTimeFormat, с запасным вариантом
UTC+3, если Intl недоступен.
2026-09-28 05:05:45 +00:00
0dcaf486c0 Часовой пояс: время по МСК независимо от часового пояса устройства
Было: nowMs() прибавляла 3 часа к epoch, а getHours()/getDay()
читали результат в местном поясе телефона. На устройстве в МСК
приложение показывало время на 3 часа вперёд.

Стало: Europe/Moscow через Intl.DateTimeFormat, с запасным вариантом
UTC+3, если Intl недоступен.
2026-09-28 05:05:43 +00:00
588b826b42 AndroidManifest: подключена иконка 2026-09-27 09:13:37 +00:00
5d78f40c3e Иконка приложения (adaptive icon) 2026-09-27 09:13:36 +00:00
362906b1ba Иконка приложения (adaptive icon) 2026-09-27 09:13:35 +00:00
93e4b2b065 Иконка приложения (adaptive icon) 2026-09-27 09:13:34 +00:00
35a43cb829 Иконка приложения (adaptive icon) 2026-09-27 09:13:33 +00:00
3e61ae7e74 README: обновлено описание v2.0 (расширенный маршрут, WebView) 2026-09-27 09:07:46 +00:00
19 changed files with 922 additions and 139 deletions

149
README.md
View File

@@ -1,78 +1,125 @@
# Автобус №127 — Расписание (Android) # Автобус №127 — Расписание (Android)
[![Лицензия: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#лицензия) Приложение с расписанием автобуса **№127** по маршруту
**Кандалакша — Полярные Зори — Зашеек** (Мурманская область).
Приложение для Android с расписанием автобуса **№127** по маршруту
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
Тёмная тема, аккуратные карточки и «чипы» расписания.
## Возможности ## Возможности
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок). - **Выбор остановки** — Кандалакша / Полярные Зори / Зашеек. При выборе
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом показываются доступные направления (куда можно уехать).
«через N минут» (время по **МСК**, обновляется каждые 30 сек). - **Ближайшие рейсы** — по 3 на каждое направление, с подсчётом
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ), «через N ч M мин» (время по **МСК**, обновляется каждые 30 сек).
аккуратно переносящиеся по 5 в строке. - **Автоопределение дня недели** — будни/выходные.
- Тёмная тема (#121212) с оранжево-красным акцентом. - **Полное расписание** — по кнопке раскрывается вся таблица
- Адаптивная иконка-автобус (Adaptive Icon). (4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- Живые часы местного времени (МСК) в шапке. - **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктом
## Скриншот «О приложении» (описание и версия сборки).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
## Технические данные ## Технические данные
| Параметр | Значение | | Параметр | Значение |
|-----------------|-----------------------------------| |-----------------|------------------------------|
| Язык | Kotlin 1.9.22 | | Тип | WebView-обёртка (HTML/JS) |
| Минимальный SDK | 26 (Android 8.0) | | Минимальный SDK | 26 (Android 8.0) |
| Целевой SDK | 34 (Android 14) | | Целевой SDK | 34 (Android 14) |
| Сборка | Gradle 8.6 + Android Gradle Plugin 8.2.2 | | Сборка | Gradle 8.6 + AGP 8.2.2 |
| UI | Material Components, ConstraintLayout | | Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
## Сборка ## Пуш-уведомления (RuStore)
Проект собирается системным Gradle (на хосте `192.168.1.46` gradle распакован Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications)
в `~/BusSchedule127/gradle-8.6`): (аналог Firebase Cloud Messaging). Проект в консоли RuStore привязан к пакету
`ru.ua1zbe.bus127` и подписи release-keystore; пока подпись не меняется, перерегистрация не нужна.
```bash Для доставки на устройстве должен быть установлен и авторизован **RuStore**.
export ANDROID_HOME=~/Android/Sdk
export JAVA_HOME=/usr
export PATH=$PATH:~/BusSchedule127/gradle-8.6/bin
cd BusSchedule127 Рассылка выполняется с сервера через API RuStore:
gradle clean assembleRelease --no-daemon
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
Content-Type: application/json
{
"message": {
"token": "{push-token устройства}",
"notification": {
"title": "Автобус 127",
"body": "Ближайший рейс через 5 мин"
}
}
}
``` ```
Подписанный APK появится в: - `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
`app/build/outputs/apk/release/app-release.apk` В приложение попадает только `project_id`; сервисный токен в APK не хранится.
- `push-token` устройства приходит в `onNewToken` (лог `RuStorePush`) и сохраняется
в `SharedPreferences` `bus127_push`.
- Приложение подписывается на топик `bus127` при старте. Отдельного создания топика
в консоли не требуется — публикация в него проходит и для несуществующего.
- Если в запросе есть секция `notification` — уведомление показывает сам SDK;
если переданы только `data` (`title`/`body`, необязательный `url`) — приложение
показывает его само и открывает ссылку по клику.
> Для подписи используется keystore `~/bus127.keystore` (alias `bus127`). ### Рассылка
> Секретные ключи в репозиторий не включены — генерируйте свои
> через `keytool` перед релизной сборкой. **Всем подписчикам топика** (все устройства, открывшие приложение):
```
POST https://vkpns-topics.rustore.ru/v1/projects/{project_id}/topics/{topic}/publish
Authorization: Bearer {service-token}
{"message":{"notification":{"title":"...","body":"..."}}}
```
**Одному устройству** — по его push-токену:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
{"message":{"token":"...","notification":{"title":"...","body":"..."}}}
```
Если устройства не подписались сами, их токены можно подписать разом:
`POST .../topics/{topic}/batch_subscribe` с `{"push_tokens":[...]}`.
Ответ `400` с текстом про `registration token` означает, что адресат не указан
или токен устройства неверен; `401`/`403` — неверный сервисный токен или его пара с `project_id`.
## Структура ## Структура
``` ```
BusSchedule127/ apk-webview/ # исходники APK (WebView)
├── app/ ├── app/
│ ├── build.gradle │ ├── build.gradle
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml │ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/MainActivity.kt │ ├── java/ru/ua1zbe/bus127/
│ └── res/ │ │ ├── MainActivity.java # главный экран: WebView + запрос разрешения
│ ├── drawable/ # фоны, чипы, иконка-автобус │ │ ├── AboutActivity.java # окно «О приложении»
│ ├── layout/ # activity_main.xml │ │ ├── Webs.java # общий WebView для всех экранов
│ ├── mipmap-anydpi-v26/ # адаптивная иконка │ │ ├── BusBridge.java # мост страницы и приложения (меню, назад, версия)
│ └── values/ # colors, strings, themes │ │ ├── Bus127App.java # инициализация RuStore Push SDK
├── build.gradle │ │ ├── PushMessagingService.java # приём уведомлений
├── settings.gradle │ │ ├── PushIntents.java # переход по клику
├── gradle.properties │ │ └── push/PushStore.java # хранение push-токена
└── README.md │ ├── assets/index.html # интерфейс (HTML/JS)
│ └── res/values/strings.xml
└── settings.gradle, build.gradle, gradle.properties
web/index.html # веб-версия (та же страница, для браузера)
``` ```
## Сборка
```bash
export JAVA_HOME=$HOME/jdk-17.0.20.1+1
export ANDROID_HOME=$HOME/Android/Sdk
cd apk-webview
~/gradle-8.6/bin/gradle assembleRelease --no-daemon
```
Готовый APK — в [релизах](../../releases) и в репозитории `android_apk`.
## Лицензия ## Лицензия
MIT — свободное использование и модификация. MIT.

View File

@@ -19,8 +19,8 @@ android {
applicationId "ru.ua1zbe.bus127" applicationId "ru.ua1zbe.bus127"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 2 versionCode 12
versionName "2.0" versionName "2.6.3"
} }
buildTypes { buildTypes {
@@ -35,3 +35,7 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
} }
dependencies {
implementation "ru.rustore.sdk:pushclient:7.4.0"
}

View File

@@ -1,20 +1,51 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".Bus127App"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<service
android:name=".PushMessagingService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
android:value="@string/push_channel_id" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@android:style/Theme.Material.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden"> android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".AboutActivity"
android:exported="false"
android:label="@string/about_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
</application> </application>
</manifest> </manifest>

View File

@@ -16,8 +16,7 @@
color:var(--txt);min-height:100vh;padding:24px 16px 48px} color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto} .wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px} header{text-align:center;margin-bottom:18px}
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase} h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
.sub{color:var(--muted);font-size:14px} .sub{color:var(--muted);font-size:14px}
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)} .clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
.clock small{color:var(--muted);font-weight:400;font-size:12px} .clock small{color:var(--muted);font-weight:400;font-size:12px}
@@ -35,6 +34,7 @@
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px} .where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px} .dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
.card h3{margin:0 0 4px;font-size:15px;font-weight:700} .card h3{margin:0 0 4px;font-size:15px;font-weight:700}
.card h3 .arr{color:var(--accent)} .card h3 .arr{color:var(--accent)}
@@ -55,9 +55,56 @@
.fullbtn:hover{border-color:var(--accent2)} .fullbtn:hover{border-color:var(--accent2)}
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)} .fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)} .fullbtn.open .chev{transform:rotate(180deg)}
.fulltitle{display:none;margin:0;padding:14px 12px 2px;font-size:16px;font-weight:700}
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
.headwrap{position:relative}
.headrow{display:grid;grid-template-columns:38px 1fr 38px;align-items:center;gap:8px}
.headrow h1{text-align:center;margin:0;min-width:0}
.iconbtn{display:none;background:var(--card2);border:1px solid var(--line);
color:var(--txt);border-radius:10px;width:38px;height:38px;
font-size:22px;line-height:1;font-family:inherit;cursor:pointer;
align-items:center;justify-content:center;padding:0}
body.app .iconbtn,body.about .iconbtn{display:inline-flex}
/* на главном экране стрелки нет, но место под неё оставляем, иначе съедет центр */
body.app .iconbtn.back{visibility:hidden}
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
#navMenu{padding-bottom:7px}
.iconbtn:active{border-color:var(--accent2)}
/* выпадающее окошко: висит поверх содержимого, прижато к кнопке меню */
.menu{position:absolute;top:calc(100% + 8px);right:0;z-index:60;
width:min(260px,100%);background:var(--card);border:1px solid var(--line);
border-radius:14px;overflow:hidden;margin:0;
box-shadow:0 18px 44px rgba(0,0,0,.6)}
.menu[hidden]{display:none}
.menuitem{display:block;width:100%;text-align:left;padding:13px 14px;border:0;
background:transparent;color:var(--txt);font-size:15px;font-weight:600;
font-family:inherit;cursor:pointer}
.menuitem:hover{background:var(--card2)}
/* экран «О приложении» */
.aboutcard{display:none;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:16px;margin-bottom:10px}
body.about .aboutcard{display:block}
body.about .pickers,body.about .where,body.about .dir,
body.about #fullTable,body.about .fullbtn{display:none}
.aboutcard h2{margin:0 0 4px;font-size:17px}
.aboutcard .ver{color:var(--muted);font-size:14px;margin-bottom:12px}
.aboutcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
.aboutcard p.muted{color:var(--muted);font-size:13px}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px; .tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px} overflow:hidden;display:none;margin-bottom:8px}
/* переключатель типа дня в полном расписании */
.days{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:12px 12px 10px;
border-bottom:1px solid var(--line)}
.days button{padding:9px 4px;border-radius:10px;border:1px solid var(--line);
background:var(--card2);color:var(--muted);font-size:13px;font-weight:600;
cursor:pointer;transition:.15s;font-family:inherit}
.days button:hover{border-color:var(--accent2);color:var(--txt)}
.days button.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#04101c;border-color:transparent}
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2); .thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
font-size:11px;color:var(--muted);text-align:center} font-size:11px;color:var(--muted);text-align:center}
.thead div{padding:11px 6px;border-left:1px solid var(--line)} .thead div{padding:11px 6px;border-left:1px solid var(--line)}
@@ -76,8 +123,17 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header> <header>
<div class="route">Мурманская область · регулярный маршрут</div> <div class="headwrap">
<h1>Автобус №127</h1> <div class="headrow">
<button class="iconbtn back" id="navBack" aria-label="Назад">‹</button>
<h1>Автобус №127</h1>
<button class="iconbtn" id="navMenu" aria-label="Меню">⋮</button>
</div>
<nav class="menu" id="menuBox" hidden>
<button class="menuitem" id="mAbout">О приложении</button>
</nav>
</div>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div> <div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div> <div class="clock" id="clock">--:--:-- <small>МСК</small></div>
<div class="day" id="dayBadge"></div> <div class="day" id="dayBadge"></div>
@@ -95,6 +151,12 @@
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button> <button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<section class="tablecard" id="fullTable"> <section class="tablecard" id="fullTable">
<h2 class="fulltitle">Полное расписание</h2>
<div class="days" id="dayPick">
<button data-day="weekday" class="active">Будни</button>
<button data-day="saturday">Суббота</button>
<button data-day="sunday">Воскресенье</button>
</div>
<div class="thead"> <div class="thead">
<div><b>Кандалакша</b>отпр.</div> <div><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>→ Зашеек</div> <div><b>Полярные Зори</b>→ Зашеек</div>
@@ -104,43 +166,142 @@
<div id="tableBody"></div> <div id="tableBody"></div>
</section> </section>
<section class="aboutcard" id="aboutBox">
<h2>О приложении</h2>
<div class="ver" id="aboutVer"></div>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.</p>
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.</p>
<p class="muted">Расписание действует с 30.09.2020. Время московское (МСК).</p>
<p class="muted">Пуш-уведомления приходят через RuStore Push — для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
</section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer> <footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
</div> </div>
<script> <script>
const SCHEDULE = [ // Расписание по типам дней недели.
["05:20","06:20","06:30","06:50"], // Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
["06:10","07:00","07:25","07:45"], // прибытие в Зашеек · Полярные Зори (обратно).
["07:40","08:30","09:00","09:20"], //
["08:00","08:50","09:20","09:50"], // Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
["09:45","10:35","11:05","11:25"], // хранились только отправления из Полярных Зорь и прибытия в Зашеек
["11:00","11:50","12:20","12:40"], // (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
["12:30","13:20","13:50","14:10"], // первая и четвёртая колонки восстановлены по тем же правилам, что и для
["13:30","14:20","14:50","15:10"], // будней, и требуют сверки с официальным расписанием:
["14:00","14:50","15:25","15:50"], // кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
["15:30","16:20","17:00","17:25"], // кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
["16:50","17:40","18:10","18:35"], const SCHEDULES = {
["18:20","19:10","19:40","20:05"], weekday: [
["20:20","21:10","21:40","22:05"] ["05:20","06:20","06:30","06:50"],
]; ["06:10","07:00","07:25","07:45"],
["07:40","08:30","09:00","09:20"],
["08:00","08:50","09:20","09:50"],
["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"],
["12:30","13:20","13:50","14:10"],
["13:30","14:20","14:50","15:10"],
["14:00","14:50","15:25","15:50"],
["15:30","16:20","17:00","17:25"],
["16:50","17:40","18:10","18:35"],
["18:20","19:10","19:40","20:05"],
["20:20","21:10","21:40","22:05"]
],
saturday: [
["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:05"],
["08:45","09:35","10:05","10:25"],
["10:10","11:00","11:30","11:50"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:45","16:05"],
["15:30","16:20","16:45","17:05"],
["17:00","17:50","18:20","18:40"],
["18:10","19:00","19:30","19:50"],
["20:20","21:10","21:40","22:00"]
],
sunday: [
["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:25","15:45"],
["16:50","17:40","18:10","18:30"],
["20:20","21:10","21:40","22:00"]
]
};
const DAY_KEYS = ["weekday","saturday","sunday"];
const DAY_TITLES = {weekday:"Будни", saturday:"Суббота", sunday:"Воскресенье"};
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка).
const RIDE_PZ_KAND = 50;
// arrs — список прибытий: {label, col} — время из колонки таблицы,
// {label, offset} — «время в пути» от отправления (если col не задан).
const DIRECTIONS = { const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [ "Кандалакша": [
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"}, {to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"} arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
], ],
"Полярные Зори": [ "Полярные Зори": [
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"}, {to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"} arrs:[{label:"Зашеек", col:2}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
], ],
"Зашеек": [ "Зашеек": [
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"}, {to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"} arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
] ]
}; };
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"]; const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; } function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
// Текущее время по МСК, независимо от часового пояса устройства
const MSK_TZ = "Europe/Moscow";
let mskFmt = null;
try {
mskFmt = new Intl.DateTimeFormat("en-US", {
timeZone: MSK_TZ, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
});
} catch(e){ mskFmt = null; }
function nowMs(){
if (mskFmt) {
try {
const parts = {};
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
return {
hour: hour,
minute: Number(parts.minute),
second: Number(parts.second),
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
Number(parts.day))).getUTCDay()
};
} catch(e){ /* ниже — запасной вариант */ }
}
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
const d = new Date(Date.now() + 3*3600*1000);
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
second: d.getUTCSeconds(), day: d.getUTCDay() };
}
function fmtHM(m){
const p = n=>String(n).padStart(2,"0");
m = ((m % 1440) + 1440) % 1440; // на случай перехода через полночь
return `${p(Math.floor(m/60))}:${p(m%60)}`;
}
function fmtWait(m){ function fmtWait(m){
if(m <= 0) return "отправление сейчас"; if(m <= 0) return "отправление сейчас";
@@ -152,24 +313,42 @@ function fmtWait(m){
let currentStop = "Полярные Зори"; let currentStop = "Полярные Зори";
// Date#getDay(): 0 — воскресенье, 6 — суббота
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
function todayKey(){ return dayKeyFor(nowMs().day); }
// Выбор пользователя — только для полного расписания
function sched(){ return SCHEDULES[activeDay]; }
function render(){ function render(){
const now = nowMs(); const now = nowMs();
const curMin = now.getHours()*60 + now.getMinutes(); const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop]; const dirs = DIRECTIONS[currentStop];
const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{ document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = SCHEDULE const upcoming = data
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri})) .map((r,ri)=>{
const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
return {label:a.label, t:fmtHM(base + (a.offset||0))};
});
return {dep, arrs, ri};
})
.filter(x=>x.dep >= curMin) .filter(x=>x.dep >= curMin)
.sort((a,b)=>a.dep-b.dep) .sort((a,b)=>a.dep-b.dep)
.slice(0,3) .slice(0,TRIPS_SHOWN)
.map(x=>{ .map(x=>{
const w = x.dep - curMin; const w = x.dep - curMin;
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : ""; // .trip .t small имеет display:block, поэтому каждый <small> — своя строка
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
return `<div class="trip ${w<15?'soon':''}"> return `<div class="trip ${w<15?'soon':''}">
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span> <span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span> <span class="w">${fmtWait(w)}</span>
</div>`; </div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`; }).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
return `<div class="card"> return `<div class="card">
<h3>→ <span class="arr">${d.to}</span></h3> <h3>→ <span class="arr">${d.to}</span></h3>
<div class="via">${d.via}</div> <div class="via">${d.via}</div>
@@ -179,20 +358,29 @@ function render(){
} }
function renderTable(){ function renderTable(){
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row => document.getElementById("tableBody").innerHTML = sched().map(row =>
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>` `<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
).join(""); ).join("");
} }
function renderDay(){ function renderDay(){
const dow = nowMs().getDay(); const dow = nowMs().day;
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`; document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
} }
// Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
function selectDay(key){
activeDay = key;
document.querySelectorAll("#dayPick button").forEach(b=>
b.classList.toggle("active", b.dataset.day === key));
renderTable();
}
function tickClock(){ function tickClock(){
const d = nowMs(); const d = nowMs();
const p = n=>String(n).padStart(2,"0"); const p = n=>String(n).padStart(2,"0");
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`; document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
} }
document.querySelectorAll(".pick").forEach(btn=>{ document.querySelectorAll(".pick").forEach(btn=>{
@@ -207,6 +395,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
}); });
}); });
document.querySelectorAll("#dayPick button").forEach(btn=>{
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
});
document.getElementById("fullBtn").addEventListener("click",()=>{ document.getElementById("fullBtn").addEventListener("click",()=>{
const t = document.getElementById("fullTable"); const t = document.getElementById("fullTable");
const b = document.getElementById("fullBtn"); const b = document.getElementById("fullBtn");
@@ -215,7 +407,38 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open); b.classList.toggle("open", !open);
}); });
tickClock(); renderDay(); render(); renderTable(); // Режим показа задаётся якорью в адресе:
// (пусто) — браузер;
// #app — главный экран приложения;
// #about — окно «О приложении».
// В приложении (не в браузере) страница дёргает нативный код через объект Bus.
const MODE = location.hash.replace("#","") || "web";
const NATIVE = MODE !== "web" && typeof Bus !== "undefined";
document.body.classList.add(MODE);
if (MODE === "about" && NATIVE) {
// версию берём из приложения, чтобы не расходилась со сборкой
const v = document.getElementById("aboutVer");
if (Bus.version && Bus.version()) v.textContent = "Версия " + Bus.version();
}
if (NATIVE) {
const menuBox = document.getElementById("menuBox");
const closeMenu = () => { menuBox.hidden = true; };
document.getElementById("navMenu").addEventListener("click",e=>{
e.stopPropagation();
menuBox.hidden = !menuBox.hidden;
});
document.getElementById("navBack").addEventListener("click",()=>Bus.goBack());
document.getElementById("mAbout").addEventListener("click",()=>Bus.openAbout());
document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
});
}
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); setInterval(renderDay,60000);

View File

@@ -0,0 +1,16 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
/**
* Описание приложения и версия. Рисуется той же страницей расписания,
* поэтому тема оформления совпадает с остальными экранами.
*/
public class AboutActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(Webs.create(this, Webs.URL_ABOUT));
}
}

View File

@@ -0,0 +1,60 @@
package ru.ua1zbe.bus127;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.util.Log;
import ru.rustore.sdk.pushclient.RuStorePushClient;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Инициализация RuStore Push SDK.
*
* project_id и topic берутся из консоли RuStore (Push-уведомления > Проекты).
* Сервисный токен в приложении не хранится — он нужен только серверу
* для вызова https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
*/
public class Bus127App extends Application {
public static final String CHANNEL_ID = "bus127_push";
@Override
public void onCreate() {
super.onCreate();
createPushChannel();
initRuStorePush();
}
private void createPushChannel() {
NotificationManager nm = getSystemService(NotificationManager.class);
nm.createNotificationChannel(new NotificationChannel(
CHANNEL_ID,
getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_HIGH));
}
private void initRuStorePush() {
try {
RuStorePushClient.INSTANCE.init(this, RUSTORE_PROJECT_ID);
RuStorePushClient.INSTANCE.getToken()
.addOnSuccessListener(token -> {
Log.i(TAG, "push token: " + token);
PushStore.setToken(this, token);
RuStorePushClient.INSTANCE.subscribeToTopic(PUSH_TOPIC)
.addOnSuccessListener(v ->
Log.i(TAG, "subscribed to " + PUSH_TOPIC))
.addOnFailureListener(e ->
Log.w(TAG, "subscribe to " + PUSH_TOPIC + " failed", e));
})
.addOnFailureListener(e ->
Log.w(TAG, "getToken failed", e));
} catch (Exception e) {
Log.w(TAG, "init failed", e);
}
}
private static final String TAG = "RuStorePush";
private static final String RUSTORE_PROJECT_ID = "upSEOtZyXP96tskl4hf-442AqciCUTxs";
private static final String PUSH_TOPIC = "bus127";
}

View File

@@ -0,0 +1,51 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.webkit.JavascriptInterface;
/**
* Мост между страницей расписания и приложением: кнопки заголовка
* (стрелка назад и меню) вызывают сюда. В браузерной версии страницы
* объект не существует, и кнопки там не показываются.
*/
public class BusBridge {
private final Activity activity;
BusBridge(Activity activity) {
this.activity = activity;
}
@JavascriptInterface
public void goBack() {
activity.runOnUiThread(activity::finish);
}
@JavascriptInterface
public void openAbout() {
open(AboutActivity.class);
}
/** «2.6.3 (сборка 12)» — чтобы текст не расходился со сборкой. */
@JavascriptInterface
public String version() {
try {
PackageInfo info = activity.getPackageManager()
.getPackageInfo(activity.getPackageName(), 0);
long code = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? info.getLongVersionCode() : info.versionCode;
return info.versionName + " (сборка " + code + ")";
} catch (PackageManager.NameNotFoundException e) {
return "";
}
}
private void open(Class<?> target) {
activity.runOnUiThread(() ->
activity.startActivity(new Intent(activity, target)));
}
}

View File

@@ -2,21 +2,24 @@ package ru.ua1zbe.bus127;
import android.app.Activity; import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.webkit.WebSettings; import android.view.WindowManager;
import android.webkit.WebView;
import android.webkit.WebViewClient;
public class MainActivity extends Activity { public class MainActivity extends Activity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
WebView webView = new WebView(this); // экран не гаснет, пока открыто расписание
WebSettings settings = webView.getSettings(); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
settings.setJavaScriptEnabled(true); requestNotificationsPermission();
settings.setDomStorageEnabled(true); setContentView(Webs.create(this, Webs.URL_MAIN));
settings.setSupportZoom(false); }
webView.setWebViewClient(new WebViewClient());
webView.loadUrl("file:///android_asset/index.html"); // Android 13+ требует явного согласия на показ уведомлений
setContentView(webView); private void requestNotificationsPermission() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU
&& checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.POST_NOTIFICATIONS}, 1);
}
} }
} }

View File

@@ -0,0 +1,20 @@
package ru.ua1zbe.bus127;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
/** Открытие расписания по клику на уведомление. */
final class PushIntents {
private PushIntents() {
}
static PendingIntent web(Context context, String url) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
}

View File

@@ -0,0 +1,75 @@
package ru.ua1zbe.bus127;
import android.app.Notification;
import android.app.NotificationManager;
import android.util.Log;
import java.util.List;
import java.util.Map;
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException;
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage;
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Приём пуш-уведомлений RuStore.
*
* Если сервер прислал секцию notification — SDK показывает уведомление сам.
* Если пришли только data (title/body) — показываем уведомление сами.
*/
public class PushMessagingService extends RuStoreMessagingService {
@Override
public void onNewToken(String token) {
Log.i(TAG, "onNewToken: " + token);
PushStore.setToken(this, token);
}
@Override
public void onMessageReceived(RemoteMessage message) {
if (message.getNotification() != null) {
Log.i(TAG, "push displayed by SDK: " + message.getData());
return;
}
Map<String, String> data = message.getData();
String title = data.get("title");
if (title == null || title.isEmpty()) {
title = getString(R.string.app_name);
}
String body = data.get("body");
if (body == null) {
body = "";
}
if (!title.isEmpty() || !body.isEmpty()) {
showPush(title, body, data.get("url"));
}
}
@Override
public void onDeletedMessages() {
Log.i(TAG, "onDeletedMessages");
}
@Override
public void onError(List<? extends RuStorePushClientException> errors) {
for (RuStorePushClientException e : errors) {
Log.w(TAG, "error", e);
}
}
private void showPush(String title, String body, String url) {
Notification.Builder b = new Notification.Builder(this, Bus127App.CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true);
if (url != null && !url.isEmpty()) {
b.setContentIntent(PushIntents.web(this, url));
}
getSystemService(NotificationManager.class).notify(NOTIFICATION_ID, b.build());
}
private static final String TAG = "RuStorePush";
private static final int NOTIFICATION_ID = 127;
}

View File

@@ -0,0 +1,30 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/** Создание WebView с одинаковыми настройками для всех экранов. */
final class Webs {
/** Главный экран: полное расписание раскрывается прямо на странице. */
static final String URL_MAIN = "file:///android_asset/index.html#app";
/** Описание приложения и версия. */
static final String URL_ABOUT = "file:///android_asset/index.html#about";
private Webs() {
}
static WebView create(Activity activity, String url) {
WebView webView = new WebView(activity);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
webView.setWebViewClient(new WebViewClient());
webView.addJavascriptInterface(new BusBridge(activity), "Bus");
webView.loadUrl(url);
return webView;
}
}

View File

@@ -0,0 +1,27 @@
package ru.ua1zbe.bus127.push;
import android.content.Context;
import android.content.SharedPreferences;
/** Хранение push-токена устройства, чтобы отправлять ему уведомления с сервера. */
public final class PushStore {
private static final String PREFS = "bus127_push";
private static final String KEY_TOKEN = "push_token";
private PushStore() {
}
private static SharedPreferences sp(Context context) {
return context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public static String getToken(Context context) {
return sp(context).getString(KEY_TOKEN, "");
}
public static void setToken(Context context, String token) {
sp(context).edit().putString(KEY_TOKEN, token).apply();
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1E1E1E" />
</shape>

View File

@@ -0,0 +1,40 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- bus body -->
<path
android:fillColor="#FF8A65"
android:pathData="M22,44 L82,44 a8,8 0 0 1 8,8 L90,80 a6,6 0 0 1 -6,6 L24,86 a6,6 0 0 1 -6,-6 L18,52 a8,8 0 0 1 8,-8 Z" />
<!-- windshield -->
<path
android:fillColor="#1E1E1E"
android:pathData="M74,48 L84,48 a4,4 0 0 1 4,4 L88,62 L74,62 Z" />
<!-- side windows -->
<path
android:fillColor="#1E1E1E"
android:pathData="M24,48 L70,48 L70,62 L24,62 Z" />
<!-- window dividers -->
<path
android:fillColor="#FF8A65"
android:pathData="M38,48 L38,62 M52,48 L52,62 M62,48 L62,62" />
<!-- headlight -->
<path
android:fillColor="#FFE0D6"
android:pathData="M84,70 L88,70 L88,76 L84,76 Z" />
<!-- wheels -->
<path
android:fillColor="#1E1E1E"
android:pathData="M34,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
<path
android:fillColor="#1E1E1E"
android:pathData="M72,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
<!-- wheel hubs -->
<path
android:fillColor="#FF8A65"
android:pathData="M34,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
<path
android:fillColor="#FF8A65"
android:pathData="M72,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Автобус 127</string> <string name="app_name">Автобус 127</string>
<string name="push_channel_id">bus127_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="about_title">О приложении</string>
</resources> </resources>

View File

@@ -2,7 +2,11 @@ pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() } repositories { google(); mavenCentral(); gradlePluginPortal() }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { google(); mavenCentral() } repositories {
google()
mavenCentral()
maven { url "https://nexus-external.rustore.ru/repository/maven-rustore-exposed/" }
}
} }
rootProject.name = "BusSchedule127" rootProject.name = "BusSchedule127"
include ':app' include ':app'

View File

@@ -16,8 +16,7 @@
color:var(--txt);min-height:100vh;padding:24px 16px 48px} color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto} .wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px} header{text-align:center;margin-bottom:18px}
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase} h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
.sub{color:var(--muted);font-size:14px} .sub{color:var(--muted);font-size:14px}
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)} .clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
.clock small{color:var(--muted);font-weight:400;font-size:12px} .clock small{color:var(--muted);font-weight:400;font-size:12px}
@@ -35,6 +34,7 @@
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px} .where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px} .dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
.card h3{margin:0 0 4px;font-size:15px;font-weight:700} .card h3{margin:0 0 4px;font-size:15px;font-weight:700}
.card h3 .arr{color:var(--accent)} .card h3 .arr{color:var(--accent)}
@@ -58,6 +58,15 @@
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px; .tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px} overflow:hidden;display:none;margin-bottom:8px}
/* переключатель типа дня в полном расписании */
.days{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:12px 12px 10px;
border-bottom:1px solid var(--line)}
.days button{padding:9px 4px;border-radius:10px;border:1px solid var(--line);
background:var(--card2);color:var(--muted);font-size:13px;font-weight:600;
cursor:pointer;transition:.15s;font-family:inherit}
.days button:hover{border-color:var(--accent2);color:var(--txt)}
.days button.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#04101c;border-color:transparent}
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2); .thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
font-size:11px;color:var(--muted);text-align:center} font-size:11px;color:var(--muted);text-align:center}
.thead div{padding:11px 6px;border-left:1px solid var(--line)} .thead div{padding:11px 6px;border-left:1px solid var(--line)}
@@ -76,7 +85,6 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header> <header>
<div class="route">Мурманская область · регулярный маршрут</div>
<h1>Автобус №127</h1> <h1>Автобус №127</h1>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div> <div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div> <div class="clock" id="clock">--:--:-- <small>МСК</small></div>
@@ -95,6 +103,11 @@
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button> <button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<section class="tablecard" id="fullTable"> <section class="tablecard" id="fullTable">
<div class="days" id="dayPick">
<button data-day="weekday" class="active">Будни</button>
<button data-day="saturday">Суббота</button>
<button data-day="sunday">Воскресенье</button>
</div>
<div class="thead"> <div class="thead">
<div><b>Кандалакша</b>отпр.</div> <div><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>→ Зашеек</div> <div><b>Полярные Зори</b>→ Зашеек</div>
@@ -108,39 +121,126 @@
</div> </div>
<script> <script>
const SCHEDULE = [ // Расписание по типам дней недели.
["05:20","06:20","06:30","06:50"], // Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
["06:10","07:00","07:25","07:45"], // прибытие в Зашеек · Полярные Зори (обратно).
["07:40","08:30","09:00","09:20"], //
["08:00","08:50","09:20","09:50"], // Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
["09:45","10:35","11:05","11:25"], // хранились только отправления из Полярных Зорь и прибытия в Зашеек
["11:00","11:50","12:20","12:40"], // (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
["12:30","13:20","13:50","14:10"], // первая и четвёртая колонки восстановлены по тем же правилам, что и для
["13:30","14:20","14:50","15:10"], // будней, и требуют сверки с официальным расписанием:
["14:00","14:50","15:25","15:50"], // кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
["15:30","16:20","17:00","17:25"], // кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
["16:50","17:40","18:10","18:35"], const SCHEDULES = {
["18:20","19:10","19:40","20:05"], weekday: [
["20:20","21:10","21:40","22:05"] ["05:20","06:20","06:30","06:50"],
]; ["06:10","07:00","07:25","07:45"],
["07:40","08:30","09:00","09:20"],
["08:00","08:50","09:20","09:50"],
["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"],
["12:30","13:20","13:50","14:10"],
["13:30","14:20","14:50","15:10"],
["14:00","14:50","15:25","15:50"],
["15:30","16:20","17:00","17:25"],
["16:50","17:40","18:10","18:35"],
["18:20","19:10","19:40","20:05"],
["20:20","21:10","21:40","22:05"]
],
saturday: [
["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:05"],
["08:45","09:35","10:05","10:25"],
["10:10","11:00","11:30","11:50"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:45","16:05"],
["15:30","16:20","16:45","17:05"],
["17:00","17:50","18:20","18:40"],
["18:10","19:00","19:30","19:50"],
["20:20","21:10","21:40","22:00"]
],
sunday: [
["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:25","15:45"],
["16:50","17:40","18:10","18:30"],
["20:20","21:10","21:40","22:00"]
]
};
const DAY_KEYS = ["weekday","saturday","sunday"];
const DAY_TITLES = {weekday:"Будни", saturday:"Суббота", sunday:"Воскресенье"};
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка).
const RIDE_PZ_KAND = 50;
// arrs — список прибытий: {label, col} — время из колонки таблицы,
// {label, offset} — «время в пути» от отправления (если col не задан).
const DIRECTIONS = { const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [ "Кандалакша": [
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"}, {to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"} arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
], ],
"Полярные Зори": [ "Полярные Зори": [
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"}, {to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"} arrs:[{label:"Зашеек", col:2}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
], ],
"Зашеек": [ "Зашеек": [
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"}, {to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"} arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
] ]
}; };
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"]; const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; } function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
// Текущее время по МСК, независимо от часового пояса устройства
const MSK_TZ = "Europe/Moscow";
let mskFmt = null;
try {
mskFmt = new Intl.DateTimeFormat("en-US", {
timeZone: MSK_TZ, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
});
} catch(e){ mskFmt = null; }
function nowMs(){
if (mskFmt) {
try {
const parts = {};
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
return {
hour: hour,
minute: Number(parts.minute),
second: Number(parts.second),
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
Number(parts.day))).getUTCDay()
};
} catch(e){ /* ниже — запасной вариант */ }
}
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
const d = new Date(Date.now() + 3*3600*1000);
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
second: d.getUTCSeconds(), day: d.getUTCDay() };
}
function fmtHM(m){
const p = n=>String(n).padStart(2,"0");
m = ((m % 1440) + 1440) % 1440; // на случай перехода через полночь
return `${p(Math.floor(m/60))}:${p(m%60)}`;
}
function fmtWait(m){ function fmtWait(m){
if(m <= 0) return "отправление сейчас"; if(m <= 0) return "отправление сейчас";
@@ -152,24 +252,42 @@ function fmtWait(m){
let currentStop = "Полярные Зори"; let currentStop = "Полярные Зори";
// Date#getDay(): 0 — воскресенье, 6 — суббота
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
function todayKey(){ return dayKeyFor(nowMs().day); }
// Выбор пользователя — только для полного расписания
function sched(){ return SCHEDULES[activeDay]; }
function render(){ function render(){
const now = nowMs(); const now = nowMs();
const curMin = now.getHours()*60 + now.getMinutes(); const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop]; const dirs = DIRECTIONS[currentStop];
const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{ document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = SCHEDULE const upcoming = data
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri})) .map((r,ri)=>{
const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
return {label:a.label, t:fmtHM(base + (a.offset||0))};
});
return {dep, arrs, ri};
})
.filter(x=>x.dep >= curMin) .filter(x=>x.dep >= curMin)
.sort((a,b)=>a.dep-b.dep) .sort((a,b)=>a.dep-b.dep)
.slice(0,3) .slice(0,TRIPS_SHOWN)
.map(x=>{ .map(x=>{
const w = x.dep - curMin; const w = x.dep - curMin;
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : ""; // .trip .t small имеет display:block, поэтому каждый <small> — своя строка
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
return `<div class="trip ${w<15?'soon':''}"> return `<div class="trip ${w<15?'soon':''}">
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span> <span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span> <span class="w">${fmtWait(w)}</span>
</div>`; </div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`; }).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
return `<div class="card"> return `<div class="card">
<h3>→ <span class="arr">${d.to}</span></h3> <h3>→ <span class="arr">${d.to}</span></h3>
<div class="via">${d.via}</div> <div class="via">${d.via}</div>
@@ -179,20 +297,29 @@ function render(){
} }
function renderTable(){ function renderTable(){
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row => document.getElementById("tableBody").innerHTML = sched().map(row =>
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>` `<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
).join(""); ).join("");
} }
function renderDay(){ function renderDay(){
const dow = nowMs().getDay(); const dow = nowMs().day;
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`; document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
} }
// Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
function selectDay(key){
activeDay = key;
document.querySelectorAll("#dayPick button").forEach(b=>
b.classList.toggle("active", b.dataset.day === key));
renderTable();
}
function tickClock(){ function tickClock(){
const d = nowMs(); const d = nowMs();
const p = n=>String(n).padStart(2,"0"); const p = n=>String(n).padStart(2,"0");
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`; document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
} }
document.querySelectorAll(".pick").forEach(btn=>{ document.querySelectorAll(".pick").forEach(btn=>{
@@ -207,6 +334,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
}); });
}); });
document.querySelectorAll("#dayPick button").forEach(btn=>{
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
});
document.getElementById("fullBtn").addEventListener("click",()=>{ document.getElementById("fullBtn").addEventListener("click",()=>{
const t = document.getElementById("fullTable"); const t = document.getElementById("fullTable");
const b = document.getElementById("fullBtn"); const b = document.getElementById("fullBtn");
@@ -215,7 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open); b.classList.toggle("open", !open);
}); });
tickClock(); renderDay(); render(); renderTable(); let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); setInterval(renderDay,60000);