Initial: BusSchedule127 Android app (Kotlin, dark theme, bus icon)

- Расписание автобуса №127 Полярные Зори — Зашеек
- Вкладки Будни/Суббота/Воскресенье
- Ближайшие рейсы с подсчётом 'через N мин' (МСК)
- Тёмная тема, Material-карточки, чипы расписания
- Адаптивная иконка-автобус
This commit is contained in:
2026-08-28 22:06:07 +03:00
commit 858f21e8d9
19 changed files with 657 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
gradle-8.6/
gradle-8.6-bin.zip
!gradle/wrapper/gradle-wrapper.jar
# Local configuration file (sdk path, etc)
local.properties
# Signing keys
*.keystore
*.jks
# Log/OS Files
*.log
# Android Studio / IntelliJ
*.iml
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# Автобус №127 — Расписание (Android)
[![Лицензия: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#лицензия)
Приложение для Android с расписанием автобуса **№127** по маршруту
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
Тёмная тема, аккуратные карточки и «чипы» расписания.
## Возможности
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок).
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом
«через N минут» (время по **МСК**, обновляется каждые 30 сек).
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ),
аккуратно переносящиеся по 5 в строке.
- Тёмная тема (#121212) с оранжево-красным акцентом.
- Адаптивная иконка-автобус (Adaptive Icon).
- Живые часы местного времени (МСК) в шапке.
## Скриншот
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
## Технические данные
| Параметр | Значение |
|-----------------|-----------------------------------|
| Язык | 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` перед релизной сборкой.
## Структура
```
BusSchedule127/
├── 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
```
## Лицензия
MIT — свободное использование и модификация.

46
app/build.gradle Normal file
View File

@@ -0,0 +1,46 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.ua1zbe.bus127'
compileSdk 34
defaultConfig {
applicationId "ru.ua1zbe.bus127"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
}
signingConfigs {
release {
storeFile file(System.getProperty("user.home") + "/bus127.keystore")
storePassword "ktdbycrbq1980"
keyAlias "bus127"
keyPassword "ktdbycrbq1980"
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="№127 Расписание"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Bus127"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,191 @@
package ru.ua1zbe.bus127
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import java.time.Duration
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class MainActivity : AppCompatActivity() {
private val mskZone = ZoneId.of("Europe/Moscow")
private val schedule = mapOf(
"weekday" to Pair(
listOf("06:20","07:00","08:30","10:35","11:50","12:20","13:20","14:20","14:50","16:20","17:40","19:10","21:10"),
listOf("06:30","07:25","09:00","11:05","12:20","12:50","13:50","14:50","15:25","17:00","18:10","19:40","21:40")
),
"saturday" to Pair(
listOf("07:00","08:20","09:35","11:00","13:20","14:50","16:20","17:50","19:00","21:10"),
listOf("07:25","08:45","10:05","11:30","13:50","15:45","16:45","18:20","19:30","21:40")
),
"sunday" to Pair(
listOf("07:00","09:35","13:20","14:50","17:40","21:10"),
listOf("07:25","10:05","13:50","15:25","18:10","21:40")
)
)
private val dayKeys = listOf("weekday", "saturday", "sunday")
private var selectedDay = 0
private lateinit var nearContainer: LinearLayout
private lateinit var toContainer: LinearLayout
private lateinit var backContainer: LinearLayout
private lateinit var dayButtons: List<Button>
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
nearContainer = findViewById(R.id.nearContainer)
toContainer = findViewById(R.id.toContainer)
backContainer = findViewById(R.id.backContainer)
val btnWeekday = findViewById<Button>(R.id.btnWeekday)
val btnSaturday = findViewById<Button>(R.id.btnSaturday)
val btnSunday = findViewById<Button>(R.id.btnSunday)
dayButtons = listOf(btnWeekday, btnSaturday, btnSunday)
dayButtons.forEachIndexed { i, btn ->
btn.setOnClickListener {
selectedDay = i
render()
}
}
render()
startClock()
}
private fun startClock() {
val clock = findViewById<TextView>(R.id.clock)
val fmt = DateTimeFormatter.ofPattern("HH:mm")
val tick = object : Runnable {
override fun run() {
clock.text = "Местное время: ${LocalTime.now(mskZone).format(fmt)} (МСК)"
handler.postDelayed(this, 30000)
}
}
tick.run()
}
private fun render() {
dayButtons.forEachIndexed { i, btn ->
if (i == selectedDay) {
btn.setBackgroundColor(Color.parseColor("#FF8A65"))
btn.setTextColor(Color.parseColor("#000000"))
} else {
btn.setBackgroundColor(Color.parseColor("#1E1E1E"))
btn.setTextColor(Color.parseColor("#E0E0E0"))
}
}
val (toList, backList) = schedule[dayKeys[selectedDay]]!!
nearContainer.removeAllViews()
val now = LocalTime.now(mskZone)
val all = mutableListOf<Triple<String, String, Long>>()
toList.forEach { t -> diffMins(now, t)?.let { all.add(Triple(t, "→", it)) } }
backList.forEach { t -> diffMins(now, t)?.let { all.add(Triple(t, "←", it)) } }
all.sortBy { it.third }
val nearest = all.take(3)
if (nearest.isEmpty()) {
val tv = TextView(this)
tv.text = "На сегодня рейсов больше нет"
tv.setTextColor(Color.parseColor("#9E9E9E"))
tv.textSize = 15f
nearContainer.addView(tv)
} else {
nearest.forEach { (t, dir, mins) ->
addNearRow(dir, t, mins)
}
}
toContainer.removeAllViews()
addChips(toContainer, toList)
backContainer.removeAllViews()
addChips(backContainer, backList)
}
private fun addNearRow(dir: String, t: String, mins: Long) {
val row = LinearLayout(this)
row.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT)
row.orientation = LinearLayout.HORIZONTAL
row.gravity = android.view.Gravity.CENTER_VERTICAL
row.setPadding(0, 6, 0, 6)
val dot = android.view.View(this)
dot.background = resources.getDrawable(R.drawable.bg_dot, null)
val dp = (10 * resources.displayMetrics.density).toInt()
val dotParams = LinearLayout.LayoutParams(dp, dp)
dotParams.setMargins(0, 0, 14, 0)
dot.layoutParams = dotParams
row.addView(dot)
val tv = TextView(this)
tv.text = "$dir $t (через ${formatMins(mins)})"
tv.setTextColor(Color.parseColor("#E0E0E0"))
tv.textSize = 17f
tv.setTypeface(null, if (mins < 30) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
row.addView(tv)
nearContainer.addView(row)
}
private fun addChips(parent: LinearLayout, times: List<String>) {
val perRow = 5
times.chunked(perRow).forEach { rowTimes ->
val row = LinearLayout(this)
row.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT)
row.orientation = LinearLayout.HORIZONTAL
row.gravity = android.view.Gravity.START
row.setPadding(0, 0, 0, 4)
rowTimes.forEach { t ->
val tv = TextView(this)
tv.text = t
tv.setTextColor(Color.parseColor("#E0E0E0"))
tv.textSize = 15f
tv.gravity = android.view.Gravity.CENTER
tv.setBackgroundResource(R.drawable.bg_chip)
val pad = (10 * resources.displayMetrics.density).toInt()
tv.setPadding(pad, pad / 2, pad, pad / 2)
val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT)
lp.setMargins(0, 0, 8, 0)
tv.layoutParams = lp
row.addView(tv)
}
parent.addView(row)
}
}
private fun diffMins(now: LocalTime, t: String): Long? {
val p = t.split(":")
val lt = LocalTime.of(p[0].toInt(), p[1].toInt())
val mins = Duration.between(now, lt).toMinutes()
return if (mins >= 0) mins else null
}
private fun formatMins(mins: Long): String {
return if (mins < 60) "$mins мин"
else {
val h = mins / 60
val m = mins % 60
"$h ч" + if (m > 0) " $m мин" else ""
}
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2A2A2A" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#3A3A3A" />
</shape>

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="oval">
<solid android:color="#FF8A65" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#FF8A65"
android:endColor="#F4511E"
android:angle="135" />
<corners android:radius="14dp" />
</shape>

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,153 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#121212">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp"
android:background="@drawable/bg_header">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🚌 Автобус №127"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Полярные Зори ⇄ Зашеек"
android:textColor="#FFE0D6"
android:textSize="15sp"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/clock"
android:text="--:--"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:layout_marginTop="8dp" />
</LinearLayout>
<!-- Day tabs -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="3"
android:layout_marginTop="14dp">
<Button
android:id="@+id/btnWeekday"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Будни"
android:layout_margin="3dp"
android:textSize="13sp" />
<Button
android:id="@+id/btnSaturday"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Суббота"
android:layout_margin="3dp"
android:textSize="13sp" />
<Button
android:id="@+id/btnSunday"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Воскр."
android:layout_margin="3dp"
android:textSize="13sp" />
</LinearLayout>
<!-- Nearest card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_chip"
android:padding="16dp"
android:layout_marginTop="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="БЛИЖАЙШИЕ РЕЙСЫ"
android:textColor="#F4511E"
android:textSize="13sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
<LinearLayout
android:id="@+id/nearContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp" />
</LinearLayout>
<!-- To card -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ТУДА ПЗ → Зашеек"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginTop="18dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/toContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<!-- Back card -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ОБРАТНО Зашеек → ПЗ"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginTop="18dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/backContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Время местное (МСК). Расписание может меняться."
android:textColor="#616161"
android:textSize="11sp"
android:gravity="center"
android:layout_marginTop="20dp" />
</LinearLayout>
</ScrollView>

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,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#121212</color>
<color name="surface">#1E1E1E</color>
<color name="surface2">#2A2A2A</color>
<color name="text">#E0E0E0</color>
<color name="muted">#9E9E9E</color>
<color name="accent">#FF8A65</color>
<color name="accentDark">#F4511E</color>
<color name="accentSoft">#FFE0D6</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">№127 Расписание</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Bus127" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:colorBackground">#121212</item>
<item name="colorPrimary">#FF8A65</item>
<item name="colorOnPrimary">#000000</item>
<item name="android:textColor">#E0E0E0</item>
<item name="colorSurface">#1E1E1E</item>
<item name="android:windowBackground">#121212</item>
</style>
</resources>

4
build.gradle Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official

16
settings.gradle Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'BusSchedule127'
include ':app'