Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fb1462af6 | |||
| e90ea69d44 | |||
| 297d61b065 | |||
| af8107b34c | |||
| 0f72977cb9 | |||
| 886849b949 | |||
| 5f8a797a58 | |||
| 3859c22086 | |||
| 0b9af4b067 | |||
| 20ae4b0606 | |||
| 0dcaf486c0 | |||
| 588b826b42 | |||
| 5d78f40c3e | |||
| 362906b1ba | |||
| 93e4b2b065 | |||
| 35a43cb829 | |||
| 3e61ae7e74 | |||
| b0d47f488d | |||
| 9300c46390 | |||
| 47eeb5bd98 | |||
| bc46d240c3 | |||
| 7f32233c85 | |||
| 1411d3a44c | |||
| d1af38f1b4 | |||
| 7fd0b3b08d | |||
| 2520f1a633 | |||
| b8a6d6d3de |
93
README.md
93
README.md
@@ -1,78 +1,55 @@
|
||||
# Автобус №127 — Расписание (Android)
|
||||
|
||||
[](#лицензия)
|
||||
|
||||
Приложение для Android с расписанием автобуса **№127** по маршруту
|
||||
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
|
||||
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
|
||||
Тёмная тема, аккуратные карточки и «чипы» расписания.
|
||||
Приложение с расписанием автобуса **№127** по маршруту
|
||||
**Кандалакша — Полярные Зори — Зашеек** (Мурманская область).
|
||||
|
||||
## Возможности
|
||||
|
||||
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок).
|
||||
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом
|
||||
«через N минут» (время по **МСК**, обновляется каждые 30 сек).
|
||||
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ),
|
||||
аккуратно переносящиеся по 5 в строке.
|
||||
- Тёмная тема (#121212) с оранжево-красным акцентом.
|
||||
- Адаптивная иконка-автобус (Adaptive Icon).
|
||||
- Живые часы местного времени (МСК) в шапке.
|
||||
|
||||
## Скриншот
|
||||
|
||||
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
|
||||
- **Выбор остановки** — Кандалакша / Полярные Зори / Зашеек. При выборе
|
||||
показываются доступные направления (куда можно уехать).
|
||||
- **Ближайшие рейсы** — по 3 на каждое направление, с подсчётом
|
||||
«через N ч M мин» (время по **МСК**, обновляется каждые 30 сек).
|
||||
- **Автоопределение дня недели** — будни/выходные.
|
||||
- **Полное расписание** — по кнопке раскрывается вся таблица
|
||||
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
|
||||
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
|
||||
|
||||
## Технические данные
|
||||
|
||||
| Параметр | Значение |
|
||||
|-----------------|-----------------------------------|
|
||||
| Язык | Kotlin 1.9.22 |
|
||||
| Минимальный SDK | 26 (Android 8.0) |
|
||||
| Целевой SDK | 34 (Android 14) |
|
||||
| Сборка | Gradle 8.6 + Android Gradle Plugin 8.2.2 |
|
||||
| UI | Material Components, ConstraintLayout |
|
||||
|
||||
## Сборка
|
||||
|
||||
Проект собирается системным Gradle (на хосте `192.168.1.46` gradle распакован
|
||||
в `~/BusSchedule127/gradle-8.6`):
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME=~/Android/Sdk
|
||||
export JAVA_HOME=/usr
|
||||
export PATH=$PATH:~/BusSchedule127/gradle-8.6/bin
|
||||
|
||||
cd BusSchedule127
|
||||
gradle clean assembleRelease --no-daemon
|
||||
```
|
||||
|
||||
Подписанный APK появится в:
|
||||
`app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
> Для подписи используется keystore `~/bus127.keystore` (alias `bus127`).
|
||||
> Секретные ключи в репозиторий не включены — генерируйте свои
|
||||
> через `keytool` перед релизной сборкой.
|
||||
| Параметр | Значение |
|
||||
|-----------------|------------------------------|
|
||||
| Тип | WebView-обёртка (HTML/JS) |
|
||||
| Минимальный SDK | 26 (Android 8.0) |
|
||||
| Целевой SDK | 34 (Android 14) |
|
||||
| Сборка | Gradle 8.6 + AGP 8.2.2 |
|
||||
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
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
|
||||
│ ├── 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.
|
||||
|
||||
37
apk-webview/app/build.gradle
Normal file
37
apk-webview/app/build.gradle
Normal file
@@ -0,0 +1,37 @@
|
||||
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 3
|
||||
versionName "2.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
22
apk-webview/app/src/main/AndroidManifest.xml
Normal file
22
apk-webview/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
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">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
255
apk-webview/app/src/main/assets/index.html
Normal file
255
apk-webview/app/src/main/assets/index.html
Normal file
@@ -0,0 +1,255 @@
|
||||
<!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}
|
||||
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
|
||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 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}
|
||||
.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}
|
||||
.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>
|
||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
||||
<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="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>
|
||||
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"],
|
||||
["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"]
|
||||
];
|
||||
const DIRECTIONS = {
|
||||
"Кандалакша": [
|
||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
||||
],
|
||||
"Полярные Зори": [
|
||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
||||
],
|
||||
"Зашеек": [
|
||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
||||
]
|
||||
};
|
||||
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 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 = "Полярные Зори";
|
||||
|
||||
function render(){
|
||||
const now = nowMs();
|
||||
const curMin = now.hour*60 + now.minute;
|
||||
const dirs = DIRECTIONS[currentStop];
|
||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||
const upcoming = SCHEDULE
|
||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
||||
.filter(x=>x.dep >= curMin)
|
||||
.sort((a,b)=>a.dep-b.dep)
|
||||
.slice(0,3)
|
||||
.map(x=>{
|
||||
const w = x.dep - curMin;
|
||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
||||
return `<div class="trip ${w<15?'soon':''}">
|
||||
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
||||
<span class="w">${fmtWait(w)}</span>
|
||||
</div>`;
|
||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[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 = SCHEDULE.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 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.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);
|
||||
});
|
||||
|
||||
tickClock(); renderDay(); render(); renderTable();
|
||||
setInterval(tickClock,1000);
|
||||
setInterval(render,30000);
|
||||
setInterval(renderDay,60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.ua1zbe.bus127;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
WebView webView = new WebView(this);
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setSupportZoom(false);
|
||||
webView.setWebViewClient(new WebViewClient());
|
||||
webView.loadUrl("file:///android_asset/index.html");
|
||||
setContentView(webView);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
4
apk-webview/app/src/main/res/values/strings.xml
Normal file
4
apk-webview/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Автобус 127</string>
|
||||
</resources>
|
||||
3
apk-webview/build.gradle
Normal file
3
apk-webview/build.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
}
|
||||
2
apk-webview/gradle.properties
Normal file
2
apk-webview/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
8
apk-webview/settings.gradle
Normal file
8
apk-webview/settings.gradle
Normal file
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories { google(); mavenCentral() }
|
||||
}
|
||||
rootProject.name = "BusSchedule127"
|
||||
include ':app'
|
||||
255
web/index.html
Normal file
255
web/index.html
Normal file
@@ -0,0 +1,255 @@
|
||||
<!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}
|
||||
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
|
||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 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}
|
||||
.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}
|
||||
.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>
|
||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
||||
<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="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>
|
||||
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"],
|
||||
["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"]
|
||||
];
|
||||
const DIRECTIONS = {
|
||||
"Кандалакша": [
|
||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
||||
],
|
||||
"Полярные Зори": [
|
||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
||||
],
|
||||
"Зашеек": [
|
||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
||||
]
|
||||
};
|
||||
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 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 = "Полярные Зори";
|
||||
|
||||
function render(){
|
||||
const now = nowMs();
|
||||
const curMin = now.hour*60 + now.minute;
|
||||
const dirs = DIRECTIONS[currentStop];
|
||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||
const upcoming = SCHEDULE
|
||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
||||
.filter(x=>x.dep >= curMin)
|
||||
.sort((a,b)=>a.dep-b.dep)
|
||||
.slice(0,3)
|
||||
.map(x=>{
|
||||
const w = x.dep - curMin;
|
||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
||||
return `<div class="trip ${w<15?'soon':''}">
|
||||
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
||||
<span class="w">${fmtWait(w)}</span>
|
||||
</div>`;
|
||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[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 = SCHEDULE.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 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.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);
|
||||
});
|
||||
|
||||
tickClock(); renderDay(); render(); renderTable();
|
||||
setInterval(tickClock,1000);
|
||||
setInterval(render,30000);
|
||||
setInterval(renderDay,60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user