88 Commits
v1.0 ... main

Author SHA1 Message Date
7d96793c27 Версия 2.6.6, сборка 15 2026-09-30 09:53:49 +00:00
4549506d6d Комментарий к версии: 2.6.6 (сборка 15) 2026-09-30 09:53:48 +00:00
d07d9407cf Системная «назад» спрашивает страницу, а не закрывает приложение 2026-09-30 09:53:47 +00:00
20ed52384a Карта в меню, чистка повторов, подпись разработчика 2026-09-30 09:53:44 +00:00
b5821a1fc7 Версия 2.6.5 (сборка 14) 2026-09-29 12:04:52 +00:00
c639996ca8 Мост JS→Java: добавлен openMap()
index.html вызывает Bus.openMap(), но метода не было в репозитории.
Сборка из исходников давала бы приложение, где пункт меню «Карта»
ничего не делает. APK 2.6.4 собирался из локальной копии, поэтому у
пользователей дефект не проявлялся. Здесь же актуализирован комментарий
к version(), значение он по-прежнему берёт из менеджера пакетов.
2026-09-29 12:04:51 +00:00
9b2a45568e Расписание по официальным данным лета 2026, прибытие и шторка на карте
Данные диспетчерской службы ООО «Гарант-Авто» на 01.05–31.10.2026,
источник kandalaksha.org/static/auto.html. Опубликованы четыре списка
ОТПОРЯЖЕНИЙ, 13/10/6 рейсов; колонка 2 — это выезд из Зашейка,
а не прибытие, из-за чего приложение показывало нелогичные интервалы.

Расписание:
- убран рейс 08:00/08:50/09:20/09:50, которого нет в источнике;
- добавлен рейс 11:30/12:20/12:50/13:20;
- в рейсе на Кандалакшу 14:10 → 14:15;
- суббота и воскресенье на 5 минут (09:05 → 09:10 и далее по списку);
- 14:20, 14:50, 15:10 и 15:50 подтверждены источником и оставлены.

Время прибытия в карточках направлений считается, а не берётся
из таблицы: Зашеек = отправление + 20 мин, но не дольше интервала
до выезда оттуда; Кандалакша = + 50 мин.

Карта:
- нижняя шторка с ручкой: перетаскивание, тап для сворачивания,
  автопрокрутка текста, карта перестраивается при изменении размера;
- заголовок «Автобус №127» в одну строку на всех экранах, раньше
  на карте он переносился, а на остальных терял пробел перед №127;
- кнопка «‹» на карте возвращает на главный экран;
- между прибытием в Зашеек и выездом автобус стоит на остановке;
- убраны 40px лишней прокрутки страницы на вкладке карты.
2026-09-29 12:04:50 +00:00
6d3f794bb7 README: карта и поведение меню 2026-09-29 08:14:57 +00:00
b5eed3284c Версия 2.6.4 2026-09-29 08:14:56 +00:00
5cdddcc9b3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:55 +00:00
36b59c2e2e Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:54 +00:00
66688630a3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:53 +00:00
9cda0ba3df Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:51 +00:00
c2505ea8f3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:50 +00:00
8daf3d01ce Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:49 +00:00
9c8d8b5db7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:48 +00:00
4f972d527b Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:46 +00:00
873a2b7cd7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:40 +00:00
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
b0d47f488d build.gradle: пароль keystore вынесен из репо 2026-09-27 08:51:29 +00:00
9300c46390 WebView-обёртка APK (исходники) 2026-09-27 08:46:58 +00:00
47eeb5bd98 WebView-обёртка APK (исходники) 2026-09-27 08:46:58 +00:00
bc46d240c3 WebView-обёртка APK (исходники) 2026-09-27 08:46:57 +00:00
7f32233c85 WebView-обёртка APK (исходники) 2026-09-27 08:46:56 +00:00
1411d3a44c WebView-обёртка APK (исходники) 2026-09-27 08:46:56 +00:00
d1af38f1b4 WebView-обёртка APK (исходники) 2026-09-27 08:46:55 +00:00
7fd0b3b08d WebView-обёртка APK (исходники) 2026-09-27 08:46:54 +00:00
2520f1a633 WebView-обёртка APK (исходники) 2026-09-27 08:46:54 +00:00
b8a6d6d3de Веб-версия расписания №127 (Кандалакша — ПЗ — Зашеек): выбор остановки, направления, ближайшие рейсы 2026-09-27 08:29:45 +00:00
26 changed files with 2245 additions and 51 deletions

160
README.md
View File

@@ -1,78 +1,142 @@
# Автобус №127 — Расписание (Android)
[![Лицензия: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#лицензия)
Приложение для Android с расписанием автобуса **№127** по маршруту
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
Тёмная тема, аккуратные карточки и «чипы» расписания.
Приложение с расписанием автобуса **№127** по маршруту
**Кандалакша — Полярные Зори — Зашеек** (Мурманская область).
## Возможности
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок).
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом
«через N минут» (время по **МСК**, обновляется каждые 30 сек).
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ),
аккуратно переносящиеся по 5 в строке.
- Тёмная тема (#121212) с оранжево-красным акцентом.
- Адаптивная иконка-автобус (Adaptive Icon).
- Живые часы местного времени (МСК) в шапке.
## Скриншот
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
- **Выбор остановки** — Кандалакша / Полярные Зори / Зашеек. При выборе
показываются доступные направления (куда можно уехать).
- **Ближайшие рейсы** — по 3 на каждое направление, с подсчётом
«через N ч M мин» (время по **МСК**, обновляется каждые 30 сек).
- **Автоопределение дня недели** — будни/выходные.
- **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Карта с автобусами** — пункт меню «Карта» открывает VK Карты: видно
пройденный участок маршрута и автобусы прямо сейчас (без подписей остановок —
они были бы только шумом). Положение считается **по расписанию** (приложение
не берёт координаты с GPS), поэтому это оценка с точностью до нескольких
минут; внизу перечислены автобусы с направлением и временем прибытия.
Обновление каждые 20 сек.
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктами
«Карта» и «О приложении». Меню сворачивается после выбора пункта, по нажатию
вне его и по кнопке «‹» (которая в этом случае только закрывает меню,
а не уходит назад).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные
| Параметр | Значение |
|-----------------|-----------------------------------|
| Язык | Kotlin 1.9.22 |
|-----------------|------------------------------|
| Тип | WebView-обёртка (HTML/JS) |
| Минимальный SDK | 26 (Android 8.0) |
| Целевой SDK | 34 (Android 14) |
| Сборка | Gradle 8.6 + Android Gradle Plugin 8.2.2 |
| UI | Material Components, ConstraintLayout |
| Сборка | Gradle 8.6 + AGP 8.2.2 |
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
## Сборка
## Пуш-уведомления (RuStore)
Проект собирается системным Gradle (на хосте `192.168.1.46` gradle распакован
в `~/BusSchedule127/gradle-8.6`):
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications)
(аналог Firebase Cloud Messaging). Проект в консоли RuStore привязан к пакету
`ru.ua1zbe.bus127` и подписи release-keystore; пока подпись не меняется, перерегистрация не нужна.
```bash
export ANDROID_HOME=~/Android/Sdk
export JAVA_HOME=/usr
export PATH=$PATH:~/BusSchedule127/gradle-8.6/bin
Для доставки на устройстве должен быть установлен и авторизован **RuStore**.
cd BusSchedule127
gradle clean assembleRelease --no-daemon
Рассылка выполняется с сервера через API RuStore:
```
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 появится в:
`app/build/outputs/apk/release/app-release.apk`
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
В приложение попадает только `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/
│ ├── build.gradle
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/MainActivity.kt
│ └── res/
│ ├── drawable/ # фоны, чипы, иконка-автобус
│ ├── layout/ # activity_main.xml
│ ├── mipmap-anydpi-v26/ # адаптивная иконка
│ └── values/ # colors, strings, themes
├── build.gradle
├── settings.gradle
├── gradle.properties
└── README.md
│ ├── java/ru/ua1zbe/bus127/
│ │ ├── MainActivity.java # главный экран: WebView + запрос разрешения
│ │ ├── AboutActivity.java # окно «О приложении»
│ │ ├── MapActivity.java # экран карты (свой WebView на тот же index.html#map)
│ │ ├── Webs.java # общий WebView для всех экранов
│ │ ├── BusBridge.java # мост страницы и приложения (меню, назад, версия)
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
│ │ ├── PushMessagingService.java # приём уведомлений
│ │ ├── PushIntents.java # переход по клику
│ │ └── push/PushStore.java # хранение push-токена
│ ├── assets/index.html # интерфейс (HTML/JS)
│ ├── assets/route.js # геометрия маршрута OSRM + координаты остановок ([lat,lon])
│ ├── assets/mmr-gl.js # VK Maps SDK (UMD, глобаль mmrgl), BSD-3-Clause
│ ├── assets/mmr-gl.css # стили SDK
│ └── 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. В `app/src/main/assets/` лежит сторонний код со своей лицензией:
- `mmr-gl.js`, `mmr-gl.css` — VK Maps SDK, BSD-3-Clause,
текст в `mmr-gl.LICENSE.txt`;
- `route.js` — геометрия дороги и координаты остановок из OpenStreetMap
(ODbL), атрибуция также выводится самими VK Картами.

View File

@@ -0,0 +1,41 @@
plugins {
id 'com.android.application'
}
android {
namespace 'ru.ua1zbe.bus127'
compileSdk 34
signingConfigs {
release {
storeFile file(System.getenv("HOME") + "/bus127.keystore")
storePassword project.findProperty("RELEASE_STORE_PASSWORD") ?: ""
keyAlias "bus127"
keyPassword project.findProperty("RELEASE_KEY_PASSWORD") ?: ""
}
}
defaultConfig {
applicationId "ru.ua1zbe.bus127"
minSdk 26
targetSdk 34
versionCode 15
versionName "2.6.6"
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation "ru.rustore.sdk:pushclient:7.4.0"
}

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<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
android:name=".Bus127App"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
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
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MapActivity"
android:exported="false"
android:label="@string/map_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity
android:name=".AboutActivity"
android:exported="false"
android:label="@string/about_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
</application>
</manifest>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
Copyright (c) 2022, VK
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MMR GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from maplibre-gl-js v3.6.2 and earlier
Version v3.6.2 of maplibre-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2023, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
const STOPS = {
kandalaksha: [67.15079, 32.40827],
polyarnye: [67.36592, 32.50103],
zasheek: [67.4066, 32.54991]
};
const LEG_KP = [[32.40830,67.15086],[32.40919,67.15123],[32.40530,67.15211],[32.40164,67.15592],[32.40263,67.15627],[32.40234,67.15671],[32.40365,67.16009],[32.40439,67.16077],[32.40083,67.16153],[32.39347,67.16167],[32.37834,67.16738],[32.39686,67.17485],[32.41082,67.17577],[32.41996,67.18286],[32.42404,67.18468],[32.43127,67.18609],[32.43515,67.19157],[32.43787,67.19296],[32.43467,67.19463],[32.43517,67.20077],[32.44001,67.20499],[32.44209,67.21166],[32.42869,67.22053],[32.42562,67.22192],[32.42445,67.22492],[32.41760,67.22676],[32.41025,67.23310],[32.40865,67.23699],[32.41144,67.24032],[32.42732,67.24995],[32.43369,67.25293],[32.43263,67.25523],[32.43282,67.26107],[32.44726,67.27306],[32.45055,67.27809],[32.45315,67.28023],[32.45327,67.28199],[32.45202,67.28569],[32.44925,67.29019],[32.44998,67.29238],[32.45527,67.29506],[32.46552,67.29702],[32.48543,67.30690],[32.49115,67.31659],[32.49412,67.31822],[32.49437,67.31942],[32.49295,67.32024],[32.47807,67.32475],[32.46672,67.33477],[32.46639,67.33576],[32.46686,67.33634],[32.47102,67.33898],[32.46966,67.34397],[32.46744,67.34585],[32.46606,67.34825],[32.46588,67.35182],[32.46764,67.35447],[32.47301,67.35645],[32.48066,67.36185],[32.48653,67.36436],[32.48729,67.36651],[32.48942,67.36782],[32.49137,67.36657],[32.49723,67.36592],[32.50077,67.36628],[32.50049,67.36658],[32.50172,67.36733],[32.50322,67.36675],[32.50112,67.36633],[32.50144,67.36598]];
const LEG_PZ = [[32.50144,67.36598],[32.50112,67.36633],[32.50322,67.36675],[32.50749,67.37359],[32.50798,67.37512],[32.50647,67.37756],[32.50977,67.38124],[32.51700,67.38728],[32.52852,67.39091],[32.53133,67.39241],[32.53116,67.39391],[32.52513,67.39734],[32.53544,67.39912],[32.54459,67.39914],[32.55436,67.40345],[32.55208,67.40677],[32.54915,67.40679]];

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,56 @@
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 openMap() {
open(MapActivity.class);
}
@JavascriptInterface
public void openAbout() {
open(AboutActivity.class);
}
/** «2.6.6 (сборка 15)» — чтобы текст не расходился со сборкой. */
@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

@@ -0,0 +1,49 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
import android.view.WindowManager;
import android.webkit.WebView;
public class MainActivity extends Activity {
private WebView web;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requestNotificationsPermission();
web = Webs.create(this, Webs.URL_MAIN);
setContentView(web);
}
/**
* WebView системную кнопку «назад» не перехватывает — без этого нажатие
* сразу закрывало бы приложение, даже когда открыта справка. Спрашиваем
* у страницы, закрыла ли она что-нибудь сама, и выходим только если нет.
*/
@Override
public void onBackPressed() {
if (web == null) {
super.onBackPressed();
return;
}
web.evaluateJavascript(
"(function(){return (window.__busBack && window.__busBack()) ? 1 : 0})()",
value -> {
String v = String.valueOf(value).replace("\"", "").trim();
if (!"1".equals(v)) finish();
});
}
// Android 13+ требует явного согласия на показ уведомлений
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,19 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
import android.view.WindowManager;
/**
* Карта маршрута с положением автобуса. Рисуется той же страницей расписания
* в режиме #map, поэтому шапка, меню и тема совпадают с остальными экранами.
* Сеть нужна только здесь: стиль и тайлы VK Карт грузятся извне.
*/
public class MapActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
setContentView(Webs.create(this, Webs.URL_MAP));
}
}

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,53 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.util.Log;
import android.webkit.ConsoleMessage;
import android.webkit.WebChromeClient;
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_MAP = "file:///android_asset/index.html#map";
/** Описание приложения и версия. */
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);
// Страница лежит в assets и грузится по file://, а карта (#map) тянет
// стиль, тайлы и шрифты VK Карт с https. Без этого file://-страница
// не может обращаться к внешним адресам, и карта остаётся пустой.
settings.setAllowUniversalAccessFromFileURLs(true);
webView.setWebViewClient(new WebViewClient());
webView.setWebChromeClient(new WebChromeClient() {
// страница — это весь интерфейс, поэтому её ошибки и предупреждения
// полезно видеть в logcat: иначе сбой на экране выглядит как «ничего не работает»
@Override
public boolean onConsoleMessage(ConsoleMessage m) {
if (m.messageLevel() == ConsoleMessage.MessageLevel.ERROR
|| m.messageLevel() == ConsoleMessage.MessageLevel.WARNING) {
Log.w("Bus127JS", m.messageLevel() + " " + m.message()
+ " (" + m.sourceId() + ":" + m.lineNumber() + ")");
return true;
}
return false;
}
});
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

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

3
apk-webview/build.gradle Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.2.2' apply false
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true

View File

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

357
web/index.html Normal file
View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Автобус №127 · Кандалакша — Полярные Зори — Зашеек</title>
<style>
:root{
--bg:#0d1220; --bg2:#131a2e; --card:#182238; --card2:#1e2a45;
--line:#2a3757; --txt:#e8edf7; --muted:#8b98b8;
--accent:#38d39f; --accent2:#4f9cff; --warn:#ffb648;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,sans-serif;
background:radial-gradient(1200px 600px at 70% -10%,#1b2a4d 0%,var(--bg) 55%);
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
.wrap{max-width:760px;margin:0 auto}
header{text-align:center;margin-bottom:18px}
h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
.sub{color:var(--muted);font-size:14px}
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
.clock small{color:var(--muted);font-weight:400;font-size:12px}
.day{display:inline-block;margin-top:8px;padding:4px 12px;border-radius:999px;
font-size:12px;border:1px solid var(--line);color:var(--muted)}
.day b{color:var(--accent2)}
.pickers{display:flex;gap:8px;margin:16px 0}
.pick{flex:1;padding:13px 8px;border-radius:12px;border:1px solid var(--line);
background:var(--card);color:var(--muted);font-size:14px;cursor:pointer;
transition:.15s;text-align:center;font-weight:600}
.pick:hover{border-color:var(--accent2);color:var(--txt)}
.pick.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
color:#04101c;border-color:transparent;box-shadow:0 6px 20px rgba(79,156,255,.25)}
.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 .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
.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 .arr{color:var(--accent)}
.card .via{color:var(--muted);font-size:11px;margin-bottom:10px;letter-spacing:.03em}
.trip{display:flex;align-items:baseline;justify-content:space-between;padding:8px 0;
border-bottom:1px dashed var(--line);font-size:15px}
.trip:last-child{border-bottom:none}
.trip .t{font-variant-numeric:tabular-nums;font-weight:700;font-size:19px}
.trip .t small{font-weight:400;color:var(--muted);font-size:11px;display:block}
.trip .w{color:var(--accent);font-size:12px;font-weight:600;white-space:nowrap}
.trip.soon .w{color:var(--warn)}
.empty{color:var(--muted);font-size:13px;padding:6px 0}
/* кнопка полного расписания */
.fullbtn{display:block;width:100%;margin:16px 0 6px;padding:12px;border-radius:12px;
border:1px solid var(--line);background:var(--card2);color:var(--txt);
font-size:14px;font-weight:600;cursor:pointer;transition:.15s}
.fullbtn:hover{border-color:var(--accent2)}
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
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);
font-size:11px;color:var(--muted);text-align:center}
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
.thead div:first-child{border-left:none}
.thead b{display:block;color:var(--txt);font-size:12px;font-weight:600}
.row{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;text-align:center;border-top:1px solid var(--line);
font-variant-numeric:tabular-nums;font-size:15px}
.row div{padding:10px 6px;border-left:1px solid var(--line)}
.row div:first-child{border-left:none}
.row:nth-child(even){background:#15203a}
footer{margin-top:22px;text-align:center;color:var(--muted);font-size:12px}
@media(max-width:560px){.dir{grid-template-columns:1fr}.pickers{gap:6px}}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Автобус №127</h1>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
<div class="day" id="dayBadge"></div>
</header>
<div class="pickers">
<button class="pick" data-stop="Кандалакша">Кандалакша</button>
<button class="pick active" data-stop="Полярные Зори">Полярные Зори</button>
<button class="pick" data-stop="Зашеек">Зашеек</button>
</div>
<div class="where" id="whereLabel">Вы в Полярных Зорях — доступные направления:</div>
<section class="dir" id="dirBox"></section>
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<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><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>→ Зашеек</div>
<div><b>Зашеек</b>прибытие</div>
<div><b>Полярные Зори</b>→ Кандалакша</div>
</div>
<div id="tableBody"></div>
</section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
</div>
<script>
// Расписание по типам дней недели.
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
// прибытие в Зашеек · Полярные Зори (обратно).
//
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
// будней, и требуют сверки с официальным расписанием:
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
const SCHEDULES = {
weekday: [
["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 = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
],
"Полярные Зори": [
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
arrs:[{label:"Зашеек", col:2}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
],
"Зашеек": [
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:3},
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
]
};
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
// Текущее время по МСК, независимо от часового пояса устройства
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){
if(m <= 0) return "отправление сейчас";
const h = Math.floor(m/60), mm = m%60;
if(h === 0) return `через ${mm} мин`;
if(mm === 0) return `через ${h} ч`;
return `через ${h} ч ${mm} мин`;
}
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(){
const now = nowMs();
const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop];
const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = data
.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)
.sort((a,b)=>a.dep-b.dep)
.slice(0,TRIPS_SHOWN)
.map(x=>{
const w = x.dep - curMin;
// .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':''}">
<span class="t">${data[x.ri][d.dep]}${arr}</span>
<span class="w">${fmtWait(w)}</span>
</div>`;
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
return `<div class="card">
<h3>→ <span class="arr">${d.to}</span></h3>
<div class="via">${d.via}</div>
${upcoming}
</div>`;
}).join("");
}
function renderTable(){
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>`
).join("");
}
function renderDay(){
const dow = nowMs().day;
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(){
const d = nowMs();
const p = n=>String(n).padStart(2,"0");
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
}
document.querySelectorAll(".pick").forEach(btn=>{
btn.addEventListener("click",()=>{
document.querySelectorAll(".pick").forEach(b=>b.classList.remove("active"));
btn.classList.add("active");
currentStop = btn.dataset.stop;
const w = {Кандалакша:"в Кандалакше", "Полярные Зори":"в Полярных Зорях", Зашеек:"в Зашейке"};
document.getElementById("whereLabel").textContent =
`Вы ${w[currentStop]} — доступные направления:`;
render();
});
});
document.querySelectorAll("#dayPick button").forEach(btn=>{
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
});
document.getElementById("fullBtn").addEventListener("click",()=>{
const t = document.getElementById("fullTable");
const b = document.getElementById("fullBtn");
const open = t.style.display === "block";
t.style.display = open ? "none" : "block";
b.classList.toggle("open", !open);
});
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000);
setInterval(render,30000);
setInterval(renderDay,60000);
</script>
</body>
</html>