|
@@ -0,0 +1,1625 @@
|
|
|
|
|
+[содержание](/readme.md)
|
|
|
|
|
+
|
|
|
|
|
+https://startandroid.ru/ru/uroki/vse-uroki-spiskom.html
|
|
|
|
|
+
|
|
|
|
|
+1. [Установка Android Studio](#Установка-Android-Studio)
|
|
|
|
|
+2. [Создание нового проекта](#Создание-нового-проекта)
|
|
|
|
|
+3. [Знакомство с интерфейсом Android Studio](#Знакомство-с-интерфейсом-Android-Studio)
|
|
|
|
|
+4. [Добавление кнопки и обработчика события onClick для нее](#Добавление-кнопки-и-обработчика-события-onClick-для-нее)
|
|
|
|
|
+5. [Проект "калькулятор"](#Проект-калькулятор)
|
|
|
|
|
+6. [Проект "Погода"](#Проект-Погода)
|
|
|
|
|
+
|
|
|
|
|
+ * [Получение текущей локации](#Получение-текущей-локации)
|
|
|
|
|
+ * [Http запросы](#Http-запросы)
|
|
|
|
|
+ * [Вывод иконки погоды](#Вывод-иконки-погоды)
|
|
|
|
|
+ * [Splash screen](#Splash-screen)
|
|
|
|
|
+ * [Получение массива данных (данные о погоде за несколько дней)](#Получение-массива-данных-данные-о-погоде-за-несколько-дней)
|
|
|
|
|
+ * [Создание дополнительной формы (список городов)](#Создание-дополнительной-формы-список-городов)
|
|
|
|
|
+
|
|
|
|
|
+7. [Проект "Достопримечательности"](#Проект-Достопримечательности)
|
|
|
|
|
+ * [Авторизация на сервере](#Авторизация-на-сервере)
|
|
|
|
|
+ * [Создание приложения с Google Maps](#Создание-приложения-с-Google-Maps)
|
|
|
|
|
+
|
|
|
|
|
+8. [Тестовый локальный сервер](#Тестовый-локальный-сервер)
|
|
|
|
|
+
|
|
|
|
|
+9. [Проект "Погода" для электронных часов (Wear OS)](#Проект-Погода-для-электронных-часов-Wear-OS)
|
|
|
|
|
+
|
|
|
|
|
+# Установка Android Studio
|
|
|
|
|
+
|
|
|
|
|
+> Внешний вид Android Studio может измениться в следующих версиях, методичка написана на основе версии 3.5 (Windows)
|
|
|
|
|
+
|
|
|
|
|
+1. [Скачать дистрибутив (версия ОС определяется автоматически)](https://developer.android.com/studio/)
|
|
|
|
|
+
|
|
|
|
|
+2. Установить Android Studio (все настройки по-умолчанию)
|
|
|
|
|
+
|
|
|
|
|
+3. Установить эмулятор
|
|
|
|
|
+
|
|
|
|
|
+ * откройте AVD Manager
|
|
|
|
|
+ 
|
|
|
|
|
+
|
|
|
|
|
+ * кликните "Create Virtual Device"
|
|
|
|
|
+ 
|
|
|
|
|
+
|
|
|
|
|
+ * выберите устройство
|
|
|
|
|
+ 
|
|
|
|
|
+
|
|
|
|
|
+ * скачайте образ (у меня уже скачан для Q) и нажмите "Next"
|
|
|
|
|
+ 
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Создание нового проекта
|
|
|
|
|
+
|
|
|
|
|
+1. Создайте новый проект (File - New - New Project...)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Выберите "Empty Activity"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. Задайте название (Name) и местоположение (Save location) проекта. Остальные настройки по-умолчанию
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Знакомство с интерфейсом Android Studio
|
|
|
|
|
+
|
|
|
|
|
+1. В панели "Project" отображается структура нашего проекта:
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ * внешний вид описывается в activity_main.xml, который расположен в app/res/layout
|
|
|
|
|
+
|
|
|
|
|
+ * программный код (MainActivity) для этого *activity* расположен в app/java/com.example.test1, где "com.example.test1" название пакета
|
|
|
|
|
+
|
|
|
|
|
+ * в центральном окне открыты закладки для activity_main и MainActivity
|
|
|
|
|
+
|
|
|
|
|
+ * в режиме **Design** на закладке activity_main отображается внешний вид выбранного экрана, палитра компонентов (с нее можно перетаскивать компоненты на форму) и аттрибуты выбранного компонента (в нашем случае TextView)
|
|
|
|
|
+
|
|
|
|
|
+ * на закладке MainActivity отображается код приложения
|
|
|
|
|
+ 
|
|
|
|
|
+ <br/>, где
|
|
|
|
|
+ * super.onCreate(savedInstanceState) - вызов конструктора базового класса
|
|
|
|
|
+ * setContentView(R.layout.activity_main) - инициализация activity
|
|
|
|
|
+
|
|
|
|
|
+# Добавление кнопки и обработчика события onClick для нее
|
|
|
|
|
+
|
|
|
|
|
+1. С панели перетаскиваем объект "Button" на форму
|
|
|
|
|
+
|
|
|
|
|
+При добавлении компонента на форму система выдает предупреждение, что нет привязок по горизонтали и вертикали - если их не задать, то все компоненты будут иметь координаты 0,0 и будут отрисовываться в левом верхнем углу. Чтобы этого не происходило нужно добавить привязку либо к сторонам формы, либо к соседним объектам. (Привязка нужна по *горизонтали* и *вертикали*, т.е. не обязательно делать все четыре привязки, достаточно двух)
|
|
|
|
|
+
|
|
|
|
|
+Привязку можно задать либо на самом объекте
|
|
|
|
|
+
|
|
|
|
|
+либо в аттрибутах
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Для объекта можно поменять цвет фона, цвет и стиль текста
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. В свойствах кнопки заполняем поле id. **Идентификатор визуального компонента одновременно является названием объекта в коде, поэтому в названии можно использовать только буквы, цифры и знак "_".** Я назвал кнопку *btn_one*.
|
|
|
|
|
+
|
|
|
|
|
+4. Переходим на закладку "MainActivity.kt" и набираем первые буквы id кнопки
|
|
|
|
|
+,
|
|
|
|
|
+<br/>выбираем полное имя из подсказки - Android Studio автоматически добавит в импорт все объекты нашей формы, т.о. мы можем обращаться к объекту не объявляя его явно<br/>
|
|
|
|
|
+<br/>
|
|
|
|
|
+ > В jave для создания ссылки на объект нужно было использовать функцию
|
|
|
|
|
+ >```java
|
|
|
|
|
+ >val btn_one = findViewById(R.id.btn_one)
|
|
|
|
|
+ >```
|
|
|
|
|
+
|
|
|
|
|
+5. Создаем обработчик на событие "click". Обработчик (слушатель) событий назначается с помощью метода setOnClickListener. Есть несколько вариантов его использования:<br/>
|
|
|
|
|
+
|
|
|
|
|
+ * первый вариант: обработчик пишется прямо в месте объявления (в конструкторе формы), подходит для мелких действий
|
|
|
|
|
+ ```kt
|
|
|
|
|
+ btn_one.setOnClickListener {
|
|
|
|
|
+ textView.text="hello"
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+ * второй вариант: создается функция обработчик
|
|
|
|
|
+
|
|
|
|
|
+ ```java
|
|
|
|
|
+ ...
|
|
|
|
|
+ btn_one.setOnClickListener(this::onClick)
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+ fun onClick(view: View){
|
|
|
|
|
+ textView.text = "hello"
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+ * третий вариант: добавить форме интерфейс "View.OnClickListener" и реализовать событие "onClick"
|
|
|
|
|
+ ```java
|
|
|
|
|
+ class MainActivity : AppCompatActivity(), View.OnClickListener {
|
|
|
|
|
+ override fun onClick(v: View?) {
|
|
|
|
|
+ textView.text = "hello"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
|
|
+ setContentView(R.layout.activity_main)
|
|
|
|
|
+
|
|
|
|
|
+ btn_one.setOnClickListener(this)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+ Можно один и тот же обработчик событий назначить нескольким объектам
|
|
|
|
|
+
|
|
|
|
|
+ ```java
|
|
|
|
|
+ btn_one.setOnClickListener(this)
|
|
|
|
|
+ btn_two.setOnClickListener(this)
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+ * четвертый вариант: в коде создать функцию обработчик и в аттрибутах кнопки свойству "onClick" присвоить эту функцию:
|
|
|
|
|
+ ```java
|
|
|
|
|
+ fun onClick(v: View) {
|
|
|
|
|
+ var tmp = textView.text.toString()
|
|
|
|
|
+ // when - это аналог switch
|
|
|
|
|
+ when(v.id) {
|
|
|
|
|
+ //R.id.btn_one - идентификатор кнопки
|
|
|
|
|
+ R.id.btn_one -> textView.text = tmp+'1'
|
|
|
|
|
+ R.id.btn_bs->{
|
|
|
|
|
+ if(tmp.isNotEmpty()) {
|
|
|
|
|
+ textView.text = tmp.take(tmp.length-1)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+ 
|
|
|
|
|
+
|
|
|
|
|
+# Проект **калькулятор**
|
|
|
|
|
+
|
|
|
|
|
+1. Установите, если еще нет, Adobe XD https://www.adobe.com/ru/products/xd.html
|
|
|
|
|
+
|
|
|
|
|
+2. Склонируйте (или скачайте) дизайн калькулятора https://github.com/kolei/wsr-calc
|
|
|
|
|
+
|
|
|
|
|
+3. Реализуйте дизайн в Android Studio (максимально близко к дизайну расположить и раскрасить компоненты: текст и кнопки) и напишите логику (обработчики нажатий на все кнопки)
|
|
|
|
|
+
|
|
|
|
|
+## Оптимизация размещения кнопок
|
|
|
|
|
+
|
|
|
|
|
+Позиционировать каждую кнопку нудно и дизайн получается девайсо-зависимым
|
|
|
|
|
+
|
|
|
|
|
+Попробуем "резиновую" верстку с помощью LinearLyout
|
|
|
|
|
+
|
|
|
|
|
+1. Очистим Activity от всех компонентов, поместим на форму LinearLayout (vertical)
|
|
|
|
|
+
|
|
|
|
|
+и "привяжем" к краям родителя
|
|
|
|
|
+
|
|
|
|
|
+2. Поместим на форму TextView для дисплея калькулятора - ширина компонента автоматически выравнивается по родительскому LinearLayout, высоту задаим позже
|
|
|
|
|
+
|
|
|
|
|
+3. Поместим на форму LinearLayout (horizontal)
|
|
|
|
|
+
|
|
|
|
|
+высоту зададим как у кнопки из дизайна
|
|
|
|
|
+
|
|
|
|
|
+4. поместим в этот контейнер кнопку
|
|
|
|
|
+
|
|
|
|
|
+и установим layout_height = match_parent
|
|
|
|
|
+после этого скопируем кнопку и вставим 3 копии
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+5. повторим пп 3-4 для всех рядов кнопок калькулятора
|
|
|
|
|
+
|
|
|
|
|
+6. Высоту TextView подгоним так, чтобы был заполнен весь Activity
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## Конкатенация строк
|
|
|
|
|
+
|
|
|
|
|
+Вообще строки склеиваются знаком "+", но свойство text объекта textView имеет тип CharSequence и конкатенацию не поддерживает, приходится делать двойное преобразование:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+btn_one.setOnClickListener{
|
|
|
|
|
+ // объявляем временную строковую переменную
|
|
|
|
|
+ var tmp = textView.text.toString()
|
|
|
|
|
+ textView.text = tmp+'1'
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Удаление последнего символа
|
|
|
|
|
+
|
|
|
|
|
+Есть несколько методов для получения подстроки, например, **take** возвращает первые **n** символов строки:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+btn_bs.setOnClickListener {
|
|
|
|
|
+ if(textView.text.length>0)
|
|
|
|
|
+ textView.text = textView.text.take(textView.text.length-1)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> Котлин компилируемый язык, в Android Studio не удобно проверять как будет работать какая-то функция. Для проверки можно использовать онлайн "проигрыватель" https://play.kotlinlang.org/
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## Добавление альбомной ориентации
|
|
|
|
|
+
|
|
|
|
|
+В режиме "design" кликаем кнопку "Orientation..." выбираем "Create Landscape Variation"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+Система автоматически создаст Layout с альбомной ориентацией.
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+> Учитывайте, что конструктор общий для всех ориентаций - при обращении к несуществующему объекту произойдет исключение. Что-бы этого не происходило, нужно либо обработчики событий для кнопок оформить отдельными функциями, либо проверять наличие объекта кнопки перед вызовом ``setOnClickListener``
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Проект **Погода**
|
|
|
|
|
+
|
|
|
|
|
+Цели:
|
|
|
|
|
+* получить текущую локацию
|
|
|
|
|
+* по сети получить погоду для текущей локации
|
|
|
|
|
+* отобразить погоду на форме
|
|
|
|
|
+* создание splash-screen
|
|
|
|
|
+* создание дополнительных форм, переход между формами, обмен данными между формами
|
|
|
|
|
+
|
|
|
|
|
+## Получение текущей локации
|
|
|
|
|
+
|
|
|
|
|
+### Стандартные средства
|
|
|
|
|
+
|
|
|
|
|
+[Тут](https://developers.google.com/android/reference/com/google/android/gms/location/package-summary) описаны стандартные интерфейсы для работы с геолокацией
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+[На основе этого примера можно посмотреть как это работает](https://en.proft.me/2019/01/3/how-get-location-latitude-longitude-android-kotlin/)
|
|
|
|
|
+
|
|
|
|
|
+1. В манифест добавляем разрешения для работы с геолокацией
|
|
|
|
|
+```
|
|
|
|
|
+<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
|
|
|
|
+<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. В build.graddle (Module: app) добавляем зависимость
|
|
|
|
|
+```
|
|
|
|
|
+implementation 'com.google.android.gms:play-services-location:11.8.0'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+Полный текст программы:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+package com.example.wheather
|
|
|
|
|
+
|
|
|
|
|
+import android.Manifest
|
|
|
|
|
+import android.app.AlertDialog
|
|
|
|
|
+import android.content.pm.PackageManager
|
|
|
|
|
+import android.location.Location
|
|
|
|
|
+import androidx.appcompat.app.AppCompatActivity
|
|
|
|
|
+import android.os.Bundle
|
|
|
|
|
+import androidx.core.app.ActivityCompat
|
|
|
|
|
+import androidx.core.content.ContextCompat
|
|
|
|
|
+import com.google.android.gms.location.FusedLocationProviderClient
|
|
|
|
|
+import com.google.android.gms.location.LocationServices
|
|
|
|
|
+import kotlinx.android.synthetic.main.activity_main.*
|
|
|
|
|
+
|
|
|
|
|
+class MainActivity : AppCompatActivity() {
|
|
|
|
|
+
|
|
|
|
|
+ var fusedLocationClient: FusedLocationProviderClient? = null
|
|
|
|
|
+
|
|
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
|
|
+ setContentView(R.layout.activity_main)
|
|
|
|
|
+
|
|
|
|
|
+ // инициализируем объект
|
|
|
|
|
+ fusedLocationClient = LocationServices.
|
|
|
|
|
+ getFusedLocationProviderClient(this)
|
|
|
|
|
+
|
|
|
|
|
+ // запрашиваем разрешение
|
|
|
|
|
+ if (checkPermission(
|
|
|
|
|
+ Manifest.permission.ACCESS_COARSE_LOCATION,
|
|
|
|
|
+ Manifest.permission.ACCESS_FINE_LOCATION))
|
|
|
|
|
+ {
|
|
|
|
|
+ fusedLocationClient?.lastLocation?.
|
|
|
|
|
+ addOnSuccessListener(this,
|
|
|
|
|
+ // Got last known location. In some rare
|
|
|
|
|
+ // situations this can be null.
|
|
|
|
|
+ {location : Location? ->
|
|
|
|
|
+ // полученные координаты выводим на экран
|
|
|
|
|
+ if(location == null) {
|
|
|
|
|
+ textView.text = "location == null"
|
|
|
|
|
+ } else location.apply {
|
|
|
|
|
+ textView.text = location.toString()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun checkPermission(vararg perm:String) : Boolean {
|
|
|
|
|
+ val PERMISSION_ID = 42
|
|
|
|
|
+
|
|
|
|
|
+ val havePermissions = perm.toList().all {
|
|
|
|
|
+ ContextCompat.checkSelfPermission(this,it) ==
|
|
|
|
|
+ PackageManager.PERMISSION_GRANTED
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!havePermissions) {
|
|
|
|
|
+ if(perm.toList().any {
|
|
|
|
|
+ ActivityCompat.
|
|
|
|
|
+ shouldShowRequestPermissionRationale(this, it)
|
|
|
|
|
+ }){
|
|
|
|
|
+ val dialog = AlertDialog.Builder(this)
|
|
|
|
|
+ .setTitle("Permission")
|
|
|
|
|
+ .setMessage("Permission needed!")
|
|
|
|
|
+ .setPositiveButton("OK", {id, v ->
|
|
|
|
|
+ ActivityCompat.requestPermissions(
|
|
|
|
|
+ this, perm, PERMISSION_ID)
|
|
|
|
|
+ })
|
|
|
|
|
+ .setNegativeButton("No", {id, v -> })
|
|
|
|
|
+ .create()
|
|
|
|
|
+ dialog.show()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ActivityCompat.requestPermissions(this, perm, PERMISSION_ID)
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Сторонние библиотеки
|
|
|
|
|
+
|
|
|
|
|
+В стандартной реализации, как обычно, слишком много букв, к счастью есть [библиотека](https://github.com/BirjuVachhani/locus-android), в которой вся рутина скрыта:
|
|
|
|
|
+
|
|
|
|
|
+1. Добавляем репозиторий в build.graddle (Project)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+maven { url 'https://jitpack.io' }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Добавляем зависимости в build.graddle (Module app)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+implementation 'com.google.android.gms:play-services-location:17.0.0'
|
|
|
|
|
+implementation 'com.github.BirjuVachhani:locus-android:3.0.1'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. В конструктор добавляем запрос геолокации:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+Locus.getCurrentLocation(this) { result ->
|
|
|
|
|
+ result.location?.let {
|
|
|
|
|
+ tv.text = "${it.latitude}, ${it.longitude}"
|
|
|
|
|
+ } ?: run {
|
|
|
|
|
+ tv.text = result.error?.message
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+Полный текст программы:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+package com.example.locator2
|
|
|
|
|
+
|
|
|
|
|
+import androidx.appcompat.app.AppCompatActivity
|
|
|
|
|
+import android.os.Bundle
|
|
|
|
|
+import com.birjuvachhani.locus.Locus
|
|
|
|
|
+import kotlinx.android.synthetic.main.activity_main.*
|
|
|
|
|
+
|
|
|
|
|
+class MainActivity : AppCompatActivity() {
|
|
|
|
|
+
|
|
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
|
|
+ setContentView(R.layout.activity_main)
|
|
|
|
|
+
|
|
|
|
|
+ Locus.getCurrentLocation(this) { result ->
|
|
|
|
|
+ result.location?.let {
|
|
|
|
|
+ tv.text = "${it.latitude}, ${it.longitude}"
|
|
|
|
|
+ } ?: run {
|
|
|
|
|
+ tv.text = result.error?.message
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Http запросы
|
|
|
|
|
+
|
|
|
|
|
+В Kotlin-е есть встроенные функции работы с http-запросами, но стандартный код для сетевых запросов сложен, излишен и в реальном мире почти не используется. Используются библиотеки. Самые популярные: [OkHttp](https://square.github.io/okhttp/) и Retrofit.
|
|
|
|
|
+
|
|
|
|
|
+Рассмотрим работу к **OkHttp**
|
|
|
|
|
+
|
|
|
|
|
+https://square.github.io/okhttp/recipes/ - примеры синхронных и асинхронных запросов на котлине
|
|
|
|
|
+
|
|
|
|
|
+### Подключение библиотеки к проекту:
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+1. На закладке **Project** в **Gradle Scripts** открываем файл **build.gradle (Module: app)**
|
|
|
|
|
+
|
|
|
|
|
+2. В файле находим секцию **dependencies** (зависимости)
|
|
|
|
|
+
|
|
|
|
|
+3. Добавляем нашу библиотеку ``implementation 'com.squareup.okhttp3:okhttp:4.2.1'``. На момент написания методички последняя версия была 4.2.1, вы можете уточнить актуальную версию на сайте.
|
|
|
|
|
+
|
|
|
|
|
+4. Синхронизируйте измения (Gradle скачает обновившиеся зависимости)
|
|
|
|
|
+
|
|
|
|
|
+5. В манифест добавляем права на доступ в интернет
|
|
|
|
|
+```
|
|
|
|
|
+<uses-permission android:name="android.permission.INTERNET" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+6. В функцию определения координат вместо вывода координат на экран вставаляем вызов функции, запрашивающей погоду для этих координат
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+Locus.getCurrentLocation(this) { result ->
|
|
|
|
|
+ result.location?.let {
|
|
|
|
|
+ //tv.text = "${it.latitude}, ${it.longitude}"
|
|
|
|
|
+
|
|
|
|
|
+ getWheather(it.longitude, it.latitude)
|
|
|
|
|
+
|
|
|
|
|
+ } ?: run {
|
|
|
|
|
+ tv.text = result.error?.message
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Вывод иконки погоды
|
|
|
|
|
+
|
|
|
|
|
+Для отображения иконки погоды используем компонент ImageView и библиотеку Glide. Для установки библиотеки:
|
|
|
|
|
+
|
|
|
|
|
+ * добавить репозиторий mavenCentral() в build.graddle (Project)
|
|
|
|
|
+ * добавить зависимость ``implementation 'com.github.bumptech.glide:glide:4.10.0'`` в build.graddle (Module)
|
|
|
|
|
+
|
|
|
|
|
+Функция запроса погоды
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// http клиент
|
|
|
|
|
+private val client = OkHttpClient()
|
|
|
|
|
+
|
|
|
|
|
+fun getWheather(lon: Double, lat: Double) {
|
|
|
|
|
+ val token = "d4c9eea0d00fb43230b479793d6aa78f"
|
|
|
|
|
+ val url = "https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${token}"
|
|
|
|
|
+
|
|
|
|
|
+ val request = Request.Builder().url(url).build()
|
|
|
|
|
+
|
|
|
|
|
+ client.newCall(request).enqueue(object : Callback {
|
|
|
|
|
+
|
|
|
|
|
+ override fun onFailure(call: Call, e: IOException) {
|
|
|
|
|
+ setText( e.toString() )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun onResponse(call: Call, response: Response) {
|
|
|
|
|
+ response.use {
|
|
|
|
|
+ if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
|
|
|
|
+
|
|
|
|
|
+ // так можно достать заголовки http-ответа
|
|
|
|
|
+ //for ((name, value) in response.headers) {
|
|
|
|
|
+ // println("$name: $value")
|
|
|
|
|
+ //}
|
|
|
|
|
+
|
|
|
|
|
+ //строку преобразуем в JSON-объект
|
|
|
|
|
+ var jsonObj = JSONObject(response.body!!.string())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // обращение к визуальному объекту из потока может вызвать исключение
|
|
|
|
|
+ // нужно присвоение делать в UI-потоке
|
|
|
|
|
+ setText( jsonObj )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fun setText(t: JSONObject){
|
|
|
|
|
+ runOnUiThread {
|
|
|
|
|
+ // достаем из ответа сервера название иконки погоды
|
|
|
|
|
+ val wheather = t.getJSONArray("weather")
|
|
|
|
|
+ val icoName = wheather.getJSONObject(0).getString("icon")
|
|
|
|
|
+ val icoUrl = "https://openweathermap.org/img/w/${icoName}.png"
|
|
|
|
|
+
|
|
|
|
|
+ // аналогично достаньте значение температуры и выведите на экран
|
|
|
|
|
+
|
|
|
|
|
+ // загружаем иконку и выводим ее на icon (ImageView)
|
|
|
|
|
+ Glide.with(this).load( icoUrl ).into( icon )
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Splash screen
|
|
|
|
|
+
|
|
|
|
|
+Запрос данных может занять несколько секунд, чтобы пользователь не смотрел на пустую форму можно добавить загрузочный экран, который будет отображать какую-то картинку, пока данные не получены.
|
|
|
|
|
+
|
|
|
|
|
+1. В каталог ``res/drawable`` положить картинку (с расширением png)
|
|
|
|
|
+2. на форму кинуть ImageView, задать фоном загруженную картинку и развернуть на весь экран
|
|
|
|
|
+3. После отрисовки полученных данных скрыть картинку ``.isVisible = false``
|
|
|
|
|
+4. Чтобы пользователь успел полюбоваться вашим Splash-скрином, можно сделать скрытие по таймеру:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// объявляем приватную переменую ``ready``, которая будет установлена при получении данных о погоде
|
|
|
|
|
+private var ready = false
|
|
|
|
|
+
|
|
|
|
|
+// и счетчик
|
|
|
|
|
+private var couter = 0
|
|
|
|
|
+
|
|
|
|
|
+...
|
|
|
|
|
+
|
|
|
|
|
+// в конструктор добавляем счечик (маскимальное время ожидания, периодические события)
|
|
|
|
|
+object : CountDownTimer(5000,1000){
|
|
|
|
|
+ override fun onTick(millisUntilFinished: Long) {
|
|
|
|
|
+ // если данные получены и прошло 3 сек, то скрываем splash screen и останавливаем счетчик
|
|
|
|
|
+ counter++
|
|
|
|
|
+ if(counter>3 && ready){
|
|
|
|
|
+ splash_screen.isVisible = false
|
|
|
|
|
+ this.cancel()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun onFinish(){
|
|
|
|
|
+ splash_screen.isVisible = false
|
|
|
|
|
+ }
|
|
|
|
|
+}.start()
|
|
|
|
|
+
|
|
|
|
|
+...
|
|
|
|
|
+
|
|
|
|
|
+Glide.with(this).load( icoUrl ).into(icon)
|
|
|
|
|
+
|
|
|
|
|
+// в конце функции получения данных выставляем флаг готовности
|
|
|
|
|
+ready = true
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Получение массива данных (данные о погоде за несколько дней)
|
|
|
|
|
+
|
|
|
|
|
+Цели:
|
|
|
|
|
+
|
|
|
|
|
+* работа с разными вариантами данных
|
|
|
|
|
+* работа с циклами
|
|
|
|
|
+* поиск визуального объекта по его ``id``
|
|
|
|
|
+
|
|
|
|
|
+### Данные о погоде за 5 дней
|
|
|
|
|
+
|
|
|
|
|
+Описание API находится [тут](https://openweathermap.org/forecast5)
|
|
|
|
|
+
|
|
|
|
|
+### Работа с циклами
|
|
|
|
|
+
|
|
|
|
|
+При получении данных за 5 дней API возвращает JSON-массив данных
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+
|
|
|
|
|
+...
|
|
|
|
|
+
|
|
|
|
|
+var list = t.getJSONArray("list")
|
|
|
|
|
+
|
|
|
|
|
+// объект JSONArray не реализует интерфейс Iterable, поэтому нужно обращаться к элементам массива по индексу
|
|
|
|
|
+for(i in 0..list.length()-1){
|
|
|
|
|
+ var item = list.getJSONObject(i)
|
|
|
|
|
+
|
|
|
|
|
+ // дальше обрабатываем как обычно
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Поиск визуального объекта по его ``id``
|
|
|
|
|
+
|
|
|
|
|
+Для отображения нескольких записей о погоде можно использовать два варианта:
|
|
|
|
|
+
|
|
|
|
|
+* в дизайнере создать n-визуальных объектов с ``id`` вида ``item_0``, ``item_1`` ..``item_(n-1)``. И потом при отрисовке получать ссылку на объект по его ``id``
|
|
|
|
|
+
|
|
|
|
|
+> Для этого проекта не пригодилось, но для ознакомления оставлю
|
|
|
|
|
+>```kt
|
|
|
|
|
+>val id = resources.
|
|
|
|
|
+> getIdentifier("item_${i}", "id ", getPackageName())
|
|
|
|
|
+>
|
|
|
|
|
+>if(id>0){
|
|
|
|
|
+> val cur_item = findViewById<TextView>(id)
|
|
|
|
|
+>
|
|
|
|
|
+> cur_item.text = температура
|
|
|
|
|
+>}
|
|
|
|
|
+>```
|
|
|
|
|
+
|
|
|
|
|
+* динамически создавать визуальные объекты для каждой записи о погоде
|
|
|
|
|
+
|
|
|
|
|
+На этом варианте остановлюсь подробнее:
|
|
|
|
|
+
|
|
|
|
|
+1. Добавляем на форму горизонтальный скролл и зададим id вложенному LinearLayout
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Добавим класс, потомок LinearLayout
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+class CustomLayout : LinearLayout {
|
|
|
|
|
+ // описываем публичные аттрибуты для текста и картинки
|
|
|
|
|
+ var tempTextView: TextView? = null
|
|
|
|
|
+ var icoImageView: ImageView? = null
|
|
|
|
|
+
|
|
|
|
|
+ // пишем конструктор
|
|
|
|
|
+ constructor(context: Context?): super(context){
|
|
|
|
|
+ this.orientation = LinearLayout.VERTICAL
|
|
|
|
|
+ this.minimumWidth = 150
|
|
|
|
|
+
|
|
|
|
|
+ // создаем TextView
|
|
|
|
|
+ tempTextView = TextView(context)
|
|
|
|
|
+ tempTextView!!.textAlignment = View.TEXT_ALIGNMENT_CENTER
|
|
|
|
|
+ this.addView(tempTextView)
|
|
|
|
|
+
|
|
|
|
|
+ // создаем ImageView
|
|
|
|
|
+ icoImageView = ImageView(context)
|
|
|
|
|
+ this.addView(icoImageView)
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Далее в основном коде в цикле обработки данных о погоде динамически создаем наш CustomLayout для каждого элемента и помещаем его в созданный скролл
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+for(i in 0..list.length()-1){
|
|
|
|
|
+ val item = CustomLayout(this)
|
|
|
|
|
+ if(item!=null) {
|
|
|
|
|
+ container.addView(item)
|
|
|
|
|
+ item.tempTextView!!.text = i.toString()
|
|
|
|
|
+ Glide.with(this).load(icoUrl).into(item.icoImageView!!)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Создание дополнительной формы (список городов)
|
|
|
|
|
+
|
|
|
|
|
+1. Создаем новую форму (Activity)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. На форму кидаем вертикальный LinearLayout, в него TextView и ListView. ListView обзываем как *cityList*
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. На основную форму добавляем кнопу перехода на экран выбора города и обработчик для нее:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+selectCity.setOnClickListener {
|
|
|
|
|
+ // при клике переходим на форму выбора города
|
|
|
|
|
+ startActivity( Intent(this, CityListActivity::class.java) )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+4. Класс CityListActivity
|
|
|
|
|
+
|
|
|
|
|
+Подробнее см [тут](http://developer.alexanderklimov.ru/android/listactivity.php)
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// создаем массив городов
|
|
|
|
|
+private var names = arrayOf(
|
|
|
|
|
+ "Moscow",
|
|
|
|
|
+ "Yoshkar-Ola",
|
|
|
|
|
+ "Kazan"
|
|
|
|
|
+)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// в конструкторе создаем адаптер для списка городов
|
|
|
|
|
+// где R.layout.city_list_item - название НОВОГО layout для элемента списка
|
|
|
|
|
+cityList.adapter = ArrayAdapter(
|
|
|
|
|
+ this,
|
|
|
|
|
+ R.layout.city_list_item, names
|
|
|
|
|
+)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Android Studio покажет ошибку, что не знает что такое ``city_list_item`` - добавляем реализацию:
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+**Внимание!** RootElement поменять на TextView
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// создаем обработчик событий выбора элемента списка
|
|
|
|
|
+cityList.setOnItemClickListener { parent, view, position, id ->
|
|
|
|
|
+ val mainIntent = Intent(this, MainActivity::class.java)
|
|
|
|
|
+ val cityName = names[id.toInt()]
|
|
|
|
|
+
|
|
|
|
|
+ // запоминаем выбранное название города
|
|
|
|
|
+ mainIntent.putExtra("city_name", cityName)
|
|
|
|
|
+
|
|
|
|
|
+ // возвращаемся на основной экран (Activity)
|
|
|
|
|
+ startActivity( mainIntent )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+В MainActivity считываем название выбранного города
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+val cityName = intent.getStringExtra("city_name")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+и делаем проверку, если город есть, то запрашиваем данные о погоде по названию города, если нет, то по старому варианту через определение координат
|
|
|
|
|
+
|
|
|
|
|
+## Передача параметров в форму
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// перед переходом в Activity можно передать параметры
|
|
|
|
|
+val mainIntent = Intent(this, MainActivity::class.java)
|
|
|
|
|
+
|
|
|
|
|
+val cityName = "Moscow"
|
|
|
|
|
+
|
|
|
|
|
+mainIntent.putExtra("city_name", cityName)
|
|
|
|
|
+
|
|
|
|
|
+startActivity( mainIntent )
|
|
|
|
|
+
|
|
|
|
|
+...
|
|
|
|
|
+
|
|
|
|
|
+// и в целевом активити, соответственно, извлечь эти данные
|
|
|
|
|
+val newCityName = intent.getStringExtra("city_name")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Хранение данных
|
|
|
|
|
+
|
|
|
|
|
+Для хранения больших массивов данных нужно использовать SQLite или аналоги, но для простых приложений можно использовать *Preferences* - хранение пар *ключ* -> *значение*:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// аттрибут класса - массив городов - пустой массив
|
|
|
|
|
+private var names = emptyArray<String>()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// запрашиваем приватное хранилище с названием "settings" (если нет, то создаст автоматически, количество хранилищ не ограничено)
|
|
|
|
|
+val myPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
|
|
|
|
+
|
|
|
|
|
+// запрашиваем из хранилища список городов (можно задать значение по-умолчанию)
|
|
|
|
|
+// андроид не позволяет хранить массивы, поэтому список хранится как строка с разделителями
|
|
|
|
|
+val oldCityListString = myPreferences.getString("cityList", "Moscow|Kazan|Yoshkar-Ola")
|
|
|
|
|
+
|
|
|
|
|
+// заполняем массив городов
|
|
|
|
|
+names = cityListString!!.split("|").toTypedArray()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Для записи данных в хранилище нужно создать объект "редактор" и после записи сохранить изменения:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+val editor = myPreferences.edit()
|
|
|
|
|
+try {
|
|
|
|
|
+ editor.putString("cityList", oldCityListString+"|"+newCityName )
|
|
|
|
|
+}finally {
|
|
|
|
|
+ editor.commit()
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## Контрольное задание по проекту **Погода**
|
|
|
|
|
+
|
|
|
|
|
+Создать приложение состоящее из трех форм (Activity)
|
|
|
|
|
+
|
|
|
|
|
+**Первый эркан**: "Заставка" (splash-screen). Показывать не менее 3-х секунд. Чтобы без толку не висело - в этом экране запрашиваем текущую локацию. Если координаты определены, то переходим на второй экран, если ошибка, то на третий
|
|
|
|
|
+
|
|
|
|
|
+**Второй экран**: основная форма с показом погоды за текущий день и список за 5 дней. На экране должна быть кнопка выбора города, по клику на которой открывается третий экран
|
|
|
|
|
+
|
|
|
|
|
+**Третий экран**: выбор города (в него попадаем при ошибке геолокации или при переходе со второго экрана). При выборе города переходим на *второй экран* и показываем погоду для выбранного города
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Проект **Достопримечательности**
|
|
|
|
|
+
|
|
|
|
|
+Цели:
|
|
|
|
|
+
|
|
|
|
|
+* Работа с сетью (GET, POST запросы, авторизация, получение данных)
|
|
|
|
|
+* Научитсья работать с Google Maps (вывод текущей позиции и создание геометок)
|
|
|
|
|
+* Научиться работать с *Custom View* - изображение достопримечательности с детальным описанием вместо текстовой метки
|
|
|
|
|
+* Построение маршрутов (по выбранным геометкам)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+Структура приложения:
|
|
|
|
|
+
|
|
|
|
|
+* **Первый экран**: заставка (в фоне авторизация на сервере и запрос геолокации). При успешной авторизации переходим на экран с картой
|
|
|
|
|
+
|
|
|
|
|
+* **Второй экран**: карта.
|
|
|
|
|
+ * При переходе на экран с сервера запрашивается список достопримечательностей
|
|
|
|
|
+ * список достопримечательностей и текущая локация отображаются на карте
|
|
|
|
|
+ * по клику на достопримечательности показывать *Custom View* и помечать (или отменять отметку) для последующего запроса построения маршрута
|
|
|
|
|
+ * при выборе достопримечательностей показывать/скрывать кнопку *Построить маршрут*
|
|
|
|
|
+ * при клике на эту кнопку отправлять запрос на построение маршрута
|
|
|
|
|
+
|
|
|
|
|
+## Авторизация на сервере
|
|
|
|
|
+
|
|
|
|
|
+> Для сетевых запросов далее будем пользоваться библиотекой [Fuel](https://github.com/kittinunf/fuel).
|
|
|
|
|
+
|
|
|
|
|
+### Установка библиотеки
|
|
|
|
|
+
|
|
|
|
|
+В зависимости приложения добавляем ``implementation 'com.github.kittinunf.fuel:fuel-android:2.2.1'`` (актуальную версию библиотеки уточняйте на сайте разработчика)
|
|
|
|
|
+
|
|
|
|
|
+> не забываем в манифесте разрешить работу с интерентом: ``<uses-permission android:name="android.permission.INTERNET"/>``
|
|
|
|
|
+
|
|
|
|
|
+В импорт добавить ``import com.github.kittinunf.result.Result`` (автоматически он не цепляется)
|
|
|
|
|
+
|
|
|
|
|
+### Примеры запросов
|
|
|
|
|
+
|
|
|
|
|
+Запросы бывают двух типов: синхронные (приложение ждет ответа от сервера, останавливая работу - такие запросы имеет смысл использовать только при авторизации, когда дальше просто нельзя двигаться) и асинхронные (запрос посылается в фоне, выполнение программы продолжается - такой режим предпочтительнее, т.к. не "замораживает" интерфейс).
|
|
|
|
|
+
|
|
|
|
|
+В последних версиях Андроид синхронные запросы в основном потоке запрещены, так что рассматривать будем только асинхронные
|
|
|
|
|
+
|
|
|
|
|
+> Вариантов авторизации существует множество, далее рассмотрен вариант, который использовался в демо-экзамене на курсах "Мастера 5000"
|
|
|
|
|
+
|
|
|
|
|
+Алгоритм авторизации:
|
|
|
|
|
+
|
|
|
|
|
+* при любом запросе должен добавляться параметр **token**
|
|
|
|
|
+* если токен не найден, то вернется сообщение, что пользователь не авторизован: ``{"notice":{"answer": "user not authorized"}}``
|
|
|
|
|
+* для получения токена необходимо послать **post** запрос на URL ``/login`` с параметрами login и password (в качестве логина используйте ИФамилия в латинской транскрипции, т.е. Евгений колесников = EKolesnikov. Пароль любой)
|
|
|
|
|
+* если токен уже был получен, то сервер вернет ошибку ``{"notice":{"answer": "User is active"}}``, в этом случае нужно разлогиниться (послать запрос с теми же параметрами на URL ``/logout``). При успешной авторизации сервер вернет токен: ``{"notice": {"token":123}``
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+> Адрес сервера динамический, уточняйте в начале лабораторной/практики
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+Fuel
|
|
|
|
|
+ .post( "http://192.168.1.18:8080/login",
|
|
|
|
|
+ listOf("login" to "ekolesnikov", "password" to "passw"))
|
|
|
|
|
+ .responseString { request, response, result ->
|
|
|
|
|
+ when (result) {
|
|
|
|
|
+ is Result.Failure -> {
|
|
|
|
|
+ // отображает сообщение на экране
|
|
|
|
|
+ Toast.makeText(applicationContext,
|
|
|
|
|
+ result.getException().toString(),
|
|
|
|
|
+ Toast.LENGTH_LONG).show()
|
|
|
|
|
+ }
|
|
|
|
|
+ is Result.Success -> {
|
|
|
|
|
+ // тут реализуете разбор полученного ответа
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+В параметрах метода **.post** передаются *URL* сервера и список параметров (логин и пароль). Метод **.responseString** срабатывает при получении ответа от сервера.
|
|
|
|
|
+
|
|
|
|
|
+Приложение может не запуститься, выдав ошибку "Cleartext HTTP traffic to ... not permitted" - в последних версиях Андроида по-умолчанию запрещено работать без ssl. Для разрешения открытого траффика добавьте в манифест в секцию **application** аттрибут ``android:usesCleartextTraffic="true"``
|
|
|
|
|
+
|
|
|
|
|
+> Как я писал выше, способов авторизации может быть несколько, в частности **Fuel** поддерживает базовую авторизацию (про нее мы говорили в прошлом году):
|
|
|
|
|
+>
|
|
|
|
|
+>```kt
|
|
|
|
|
+>Fuel.get("https://httpbin.org/basic-auth/$user/$password")
|
|
|
|
|
+> .authentication()
|
|
|
|
|
+> .basic(username, password)
|
|
|
|
|
+> .response { result -> }
|
|
|
|
|
+>```
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## Создание приложения с Google Maps
|
|
|
|
|
+
|
|
|
|
|
+Основано на этой [статье](http://developer.alexanderklimov.ru/android/google_maps.php) и [этой](https://startandroid.ru/ru/uroki/vse-uroki-spiskom/306-urok-139-google-maps-sozdanie-i-nastrojka-proekta-karta-kamera-sobytija.html) и [этой](https://www.raywenderlich.com/230-introduction-to-google-maps-api-for-android-with-kotlin)
|
|
|
|
|
+
|
|
|
|
|
+1. Добавляем в приложение новое Activitu, выбрав форму "Google Maps Activity"
|
|
|
|
|
+
|
|
|
|
|
+После создания Activity Android Studio автоматически создаст и откроет файл для генерации ключа.
|
|
|
|
|
+
|
|
|
|
|
+1. Перейдите по ссылке
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Создайте новый проект
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. Создайте ключ API
|
|
|
|
|
+
|
|
|
|
|
+4. Полученный ключ вставьте в google_maps_api.xml (файл из которого переходили по ссылке)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+И немного поправим стандартный метод **onMapReady**
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+override fun onMapReady(googleMap: GoogleMap) {
|
|
|
|
|
+ mMap = googleMap
|
|
|
|
|
+
|
|
|
|
|
+ // координаты техникума
|
|
|
|
|
+ val yotc = LatLng(56.639439, 47.892384)
|
|
|
|
|
+
|
|
|
|
|
+ // к метке добавлена подпись
|
|
|
|
|
+ mMap.addMarker(MarkerOptions().position(yotc).title("Метка ЙОТК")
|
|
|
|
|
+ .snippet("Йошкар-Олинский Технологический Коледж"))
|
|
|
|
|
+
|
|
|
|
|
+ // используем камеру с масштабированием
|
|
|
|
|
+ mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(yotc, 16F))
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Добавление текущей позиции
|
|
|
|
|
+
|
|
|
|
|
+1. В build.graddle (app) добавляем зависимость ``implementation 'com.google.android.gms:play-services-location:17.0.0'``
|
|
|
|
|
+
|
|
|
|
|
+2. Добавляем приватные аттрибуты класса:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ private var lat: Double = 0.0
|
|
|
|
|
+ private var lon: Double = 0.0
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+3. В конструкторе инициализируем локатор (зависимости для локатора см. выше, мы его использовали в проекте **Погода**):
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+Locus.getCurrentLocation(this) { result ->
|
|
|
|
|
+ result.location?.let {
|
|
|
|
|
+ lat = it.latitude
|
|
|
|
|
+ lon = it.longitude
|
|
|
|
|
+
|
|
|
|
|
+ // тут можно вызвать функцию отображения текущей геолокации
|
|
|
|
|
+
|
|
|
|
|
+ } ?: run {
|
|
|
|
|
+ error = "${error}${result.error?.message}\n"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Включение слоя с текущей геолокацией:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+// на карте включаем слой с текущей локацией
|
|
|
|
|
+mMap.isMyLocationEnabled = true
|
|
|
|
|
+
|
|
|
|
|
+currentLatLng = LatLng(lat, lon)
|
|
|
|
|
+mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(currentLatLng, 14f))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+4. В методе ``onMapReady`` запрашиваем список достопримечательностей (можно и в конструкторе, но пока карты нет - нет смысла в этих данных)
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+Fuel.post("http://192.168.1.18:8080/points",
|
|
|
|
|
+ listOf("token" to token))
|
|
|
|
|
+ .responseString{request, response, result ->
|
|
|
|
|
+
|
|
|
|
|
+ // тут разбираем ответ сервера
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+В этом же методе можно прикрутить **слушателя** на событие клика по геометкам (по клику мы меняем цвет маркера и показываем/скрываем кнопку расчета маршрута):
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+mMap.setOnMarkerClickListener(object : GoogleMap.OnMarkerClickListener {
|
|
|
|
|
+ override fun onMarkerClick(marker: Marker): Boolean {
|
|
|
|
|
+ if(marker.tag==0) {
|
|
|
|
|
+ marker.tag = 1
|
|
|
|
|
+ marker.setIcon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ marker.tag = 0
|
|
|
|
|
+ marker.setIcon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // в этой функции показываем/скрываем кнопку Расчитать маршрут
|
|
|
|
|
+ checkMarkers()
|
|
|
|
|
+
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+То ли в API Google Maps нет методов для получения списка маркеров, то ли я не смог их найти, но в инете все хранят маркеры в отдельном массиве:
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ private var marker_list = arrayListOf<Marker>()
|
|
|
|
|
+
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+ //в функции добавления маркетов на карту мы должны добавить их в этот список
|
|
|
|
|
+ val marker = MarkerOptions()
|
|
|
|
|
+ .position(coord)
|
|
|
|
|
+ .title( point.getString("short") )
|
|
|
|
|
+ .icon(
|
|
|
|
|
+ BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED))
|
|
|
|
|
+
|
|
|
|
|
+ marker_list.add( mMap.addMarker(marker) )
|
|
|
|
|
+
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+ private fun checkMarkers(){
|
|
|
|
|
+ for (marker in marker_list){
|
|
|
|
|
+ if (marker.tag==1){
|
|
|
|
|
+ btn_route.isVisible = true
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ btn_route.isVisible = false
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Добавление своих объектов
|
|
|
|
|
+
|
|
|
|
|
+Основано на [этой](https://startandroid.ru/ru/uroki/vse-uroki-spiskom/307-urok-140-google-maps-svoi-obekty-na-karte.html) статье
|
|
|
|
|
+
|
|
|
|
|
+1. Запросите список достопримечательностей по адресу http://<адрес сервера>:8080/points. Тип запроса: POST, в параметрах токен.
|
|
|
|
|
+
|
|
|
|
|
+2. Добавьте маркеры на карту
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ private fun getPoints(){
|
|
|
|
|
+ Fuel.post("http://192.168.1.18:8080/points",
|
|
|
|
|
+ listOf("token" to token))
|
|
|
|
|
+ .responseString{request, response, result ->
|
|
|
|
|
+ when (result) {
|
|
|
|
|
+ is Result.Failure ->
|
|
|
|
|
+ Toast.makeText(applicationContext,
|
|
|
|
|
+ "Get points failure: ${result.getException()}",
|
|
|
|
|
+ Toast.LENGTH_LONG).show()
|
|
|
|
|
+ is Result.Success ->
|
|
|
|
|
+ try{
|
|
|
|
|
+ val jsonResp = JSONObject(result.get())
|
|
|
|
|
+ if (jsonResp.has("status") && jsonResp.getString("status")=="OK"){
|
|
|
|
|
+ // добавляем точки на карту
|
|
|
|
|
+ val points = jsonResp.getJSONArray("points")
|
|
|
|
|
+ for (i in 0 until points.length()){
|
|
|
|
|
+ val point = points.getJSONObject(i)
|
|
|
|
|
+
|
|
|
|
|
+ val coord = LatLng(point.getDouble("lat"), point.getDouble("lon"))
|
|
|
|
|
+
|
|
|
|
|
+ val marker = MarkerOptions()
|
|
|
|
|
+ .position(coord)
|
|
|
|
|
+ .title( point.getString("short") )
|
|
|
|
|
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED))
|
|
|
|
|
+
|
|
|
|
|
+ marker_list.add( mMap.addMarker(marker) )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ throw Exception("Не верный формат ответа сервера или ошибка")
|
|
|
|
|
+ }catch(e: Exception){
|
|
|
|
|
+ Toast.makeText(applicationContext,
|
|
|
|
|
+ "Get points failure: ${e.message}",
|
|
|
|
|
+ Toast.LENGTH_LONG).show()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Добавление кнопки
|
|
|
|
|
+
|
|
|
|
|
+В дизайнере кнопка на фрагмент с картой не перетаскивается, добавить можно прямо в XML:
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+ <Button
|
|
|
|
|
+ android:id="@+id/btn_route"
|
|
|
|
|
+ android:layout_width="wrap_content"
|
|
|
|
|
+ android:layout_height="wrap_content"
|
|
|
|
|
+ android:layout_gravity="top|left"
|
|
|
|
|
+ android:layout_marginTop="10dp"
|
|
|
|
|
+ android:padding="10dp"
|
|
|
|
|
+ android:paddingRight="10dp"
|
|
|
|
|
+ android:text="Построить маршрут"
|
|
|
|
|
+ android:visibility="invisible" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+В дизайнере она опять же видна не будет, но в структуре формы появится и на экране отобразится
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+### Добавление маршрута
|
|
|
|
|
+
|
|
|
|
|
+Если интересно, можете прикрутить себе настоящий запрос к Directions API, но это API платное (хотя первый год денег не берут, но все-равно нужно привязывать карту). Но можете пропустить комментарий
|
|
|
|
|
+
|
|
|
|
|
+> 1. В зависимости добавим пакет ``implementation 'com.google.maps.android:android-maps-utils:0.5'``
|
|
|
|
|
+>
|
|
|
|
|
+> 2. Получаем ключ для работы с API: переходим по [ссылке](https://cloud.google.com/maps-platform/pricing/), жмем **Get Started** (на цену не обращаем внимания, первый год пользования бесплатный). Если к вашему аккаунту не привязан биллинг, то нужно привязать.
|
|
|
|
|
+>
|
|
|
|
|
+> * выбираем продукт (Routes)
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+> * выбираем проект, для которого нужен ключ. Выбирайте тот проект, который был создан для Google Maps
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+> * включаем API
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+> * копируем созданный ключ и вставляем его приватной переменной класса (вроде правильнее хранить в манифесте или ресурсах, но пока это не принципиально)
|
|
|
|
|
+>
|
|
|
|
|
+>
|
|
|
|
|
+
|
|
|
|
|
+Чтобы не активировать кучу ключей, я сделал на локальном сервере проксирование этих запросов. Теперь вместо ``https://maps.googleapis.com/maps/api/directions/json?`` можно писать ``http://192.168.1.18:8080/directions?``. Ключ можно не указывать, все остальные параметры передаются как есть (не забываем добавить **token**).
|
|
|
|
|
+
|
|
|
|
|
+Из текущей локации и выбранных точек формируем запрос маршрута (маршрут зацикленный, откуда вышли, туда и вернемся):
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+
|
|
|
|
|
+// очищаем предыдущий маршрут
|
|
|
|
|
+if(lastRoute!=null) lastRoute!!.remove()
|
|
|
|
|
+
|
|
|
|
|
+// формируем список путевых точек https://developers.google.com/maps/documentation/directions/intro#DirectionsRequests
|
|
|
|
|
+
|
|
|
|
|
+// wayponts=lat1,lon1[|latN,lonN]
|
|
|
|
|
+
|
|
|
|
|
+var waypoints = ""
|
|
|
|
|
+
|
|
|
|
|
+for (marker in marker_list){
|
|
|
|
|
+ if(marker.tag==1){
|
|
|
|
|
+ if(waypoints!="") waypoints += "|"
|
|
|
|
|
+ waypoints += "${marker.position.latitude},${marker.position.longitude}"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if(waypoints!=""){
|
|
|
|
|
+ Fuel.get("http://192.168.1.18:8080/directions", listOf(
|
|
|
|
|
+ "origin" to "${lat},${lon}",
|
|
|
|
|
+ "destination" to "${lat},${lon}",
|
|
|
|
|
+ "waypoints" to waypoints,
|
|
|
|
|
+ "mode" to "walking",
|
|
|
|
|
+ "language" to "ru",
|
|
|
|
|
+ "token" to token))
|
|
|
|
|
+ .responseString { request, response, result ->
|
|
|
|
|
+ when(result){
|
|
|
|
|
+ is Result.Failure ->
|
|
|
|
|
+ Toast.makeText(this, result.getException().toString(), Toast.LENGTH_LONG).show()
|
|
|
|
|
+ is Result.Success ->
|
|
|
|
|
+ try {
|
|
|
|
|
+ val resp = JSONObject(result.get())
|
|
|
|
|
+
|
|
|
|
|
+ if(resp.has("error_message"))
|
|
|
|
|
+ throw Exception(resp.getString("error_message"))
|
|
|
|
|
+
|
|
|
|
|
+ val legs = resp.getJSONArray("routes").getJSONObject(0).getJSONArray("legs")
|
|
|
|
|
+
|
|
|
|
|
+ if (resp.getString("status") != "OK")
|
|
|
|
|
+ throw Exception("status: error")
|
|
|
|
|
+
|
|
|
|
|
+ //Линия которую будем рисовать
|
|
|
|
|
+ val line = PolylineOptions()
|
|
|
|
|
+ val latLngBuilder = LatLngBounds.Builder()
|
|
|
|
|
+
|
|
|
|
|
+ for (j in 0 until legs.length()) {
|
|
|
|
|
+ val steps= legs.getJSONObject(j).getJSONArray("steps")
|
|
|
|
|
+
|
|
|
|
|
+ //Проходимся по всем точкам, добавляем их в Polyline и в LanLngBounds.Builder
|
|
|
|
|
+ for (i in 0 until steps.length()) {
|
|
|
|
|
+ var point = LatLng(
|
|
|
|
|
+ steps.getJSONObject(i).getJSONObject("start_location").getDouble(
|
|
|
|
|
+ "lat"
|
|
|
|
|
+ ),
|
|
|
|
|
+ steps.getJSONObject(i).getJSONObject("start_location").getDouble(
|
|
|
|
|
+ "lng"
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ line.add(point)
|
|
|
|
|
+ latLngBuilder.include(point)
|
|
|
|
|
+
|
|
|
|
|
+ point = LatLng(
|
|
|
|
|
+ steps.getJSONObject(i).getJSONObject("end_location").getDouble(
|
|
|
|
|
+ "lat"
|
|
|
|
|
+ ),
|
|
|
|
|
+ steps.getJSONObject(i).getJSONObject("end_location").getDouble(
|
|
|
|
|
+ "lng"
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ line.add(point)
|
|
|
|
|
+ latLngBuilder.include(point)
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ //Делаем линию более менее симпатичное
|
|
|
|
|
+ line.width(16f).color(R.color.colorPrimary)
|
|
|
|
|
+
|
|
|
|
|
+ //Добавляем линию на карту
|
|
|
|
|
+ lastRoute = mMap.addPolyline(line)
|
|
|
|
|
+
|
|
|
|
|
+ //Выставляем камеру на нужную нам позицию
|
|
|
|
|
+ val latLngBounds = latLngBuilder.build()
|
|
|
|
|
+
|
|
|
|
|
+ val track = CameraUpdateFactory.newLatLngBounds(
|
|
|
|
|
+ latLngBounds,
|
|
|
|
|
+ 500,
|
|
|
|
|
+ 500,
|
|
|
|
|
+ 25
|
|
|
|
|
+ )//width это размер нашего экрана
|
|
|
|
|
+
|
|
|
|
|
+ mMap.moveCamera(track)
|
|
|
|
|
+
|
|
|
|
|
+ } catch (e: Exception){
|
|
|
|
|
+ Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e: ApiException) {
|
|
|
|
|
+ e.printStackTrace()
|
|
|
|
|
+ Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()
|
|
|
|
|
+ } catch (e: InterruptedException) {
|
|
|
|
|
+ e.printStackTrace()
|
|
|
|
|
+ Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()
|
|
|
|
|
+ } catch (e: IOException) {
|
|
|
|
|
+ e.printStackTrace()
|
|
|
|
|
+ Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Отображение пользовательского экрана информации о маркере
|
|
|
|
|
+
|
|
|
|
|
+Информационное окно маркера отображает canvas (фактически скриншот формы), поэтому при отложенной загрузке картинок из интернета приходится извращаться - ловить завершение загрузки ресурса и снова показывать информационное окно.
|
|
|
|
|
+
|
|
|
|
|
+1. В ``res\layout`` добавить шаблон для окна ``custom_infowindow.xml`` (кликаем правой кнопкой по папке layout и создаем Layout resource file)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+Примерное содержимое: ImageView и TextView
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<?xml version="1.0" encoding="utf-8"?>
|
|
|
|
|
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
|
|
|
+ android:layout_width="200dp"
|
|
|
|
|
+ android:layout_height="wrap_content"
|
|
|
|
|
+ android:orientation="vertical">
|
|
|
|
|
+
|
|
|
|
|
+ <ImageView
|
|
|
|
|
+ android:id="@+id/image"
|
|
|
|
|
+ android:layout_width="match_parent"
|
|
|
|
|
+ android:layout_height="200dp"
|
|
|
|
|
+ android:maxWidth="200dp" />
|
|
|
|
|
+
|
|
|
|
|
+ <TextView
|
|
|
|
|
+ android:id="@+id/tv"
|
|
|
|
|
+ android:layout_width="match_parent"
|
|
|
|
|
+ android:layout_height="wrap_content"
|
|
|
|
|
+ android:layout_marginTop="10dp"
|
|
|
|
|
+ android:text="Custom Info layout textview"
|
|
|
|
|
+ android:textColor="@color/colorAccent"
|
|
|
|
|
+ android:textSize="20sp" />
|
|
|
|
|
+
|
|
|
|
|
+</LinearLayout>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+2. В функцию ``onMapReady`` добавляем строчку ``mMap.setInfoWindowAdapter( CustomInfoWindowAdapter() )`` - функция setInfoWindowAdapter задает пользовательский класс для отрисовки информационного окна
|
|
|
|
|
+
|
|
|
|
|
+3. Делаем класс, для хранения урла картинки и описания для маркера
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ // массив маркеров хранит новый тип данных
|
|
|
|
|
+ private var marker_list = arrayListOf<MyMarker>()
|
|
|
|
|
+
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+ internal inner class MyMarker {
|
|
|
|
|
+ var marker: Marker
|
|
|
|
|
+ var url: String
|
|
|
|
|
+ var description: String
|
|
|
|
|
+ var downloaded = false
|
|
|
|
|
+
|
|
|
|
|
+ constructor(_marker: Marker, _url: String, _desc: String){
|
|
|
|
|
+ marker = _marker
|
|
|
|
|
+ url = _url
|
|
|
|
|
+ description = _desc
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+и при создании маркера заполняем эти параметры
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ val mymarker = MyMarker(mMap.addMarker(marker) ,
|
|
|
|
|
+ point.getString("img"),
|
|
|
|
|
+ point.getString("description"))
|
|
|
|
|
+
|
|
|
|
|
+ marker_list.add( mymarker )
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+3. Реализуем класс ``CustomInfoWindowAdapter`` (объявляем внутренний класс прямо внутри класса активити) наследник ``GoogleMap.InfoWindowAdapter``
|
|
|
|
|
+
|
|
|
|
|
+> Нашел примеры для загрузки картинок с callback-ом с использованием библиотеки Picasso
|
|
|
|
|
+> ``implementation 'com.squareup.picasso:picasso:2.71828'``
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+internal inner class CustomInfoWindowAdapter : GoogleMap.InfoWindowAdapter {
|
|
|
|
|
+ fun getImgUrl4Marker(marker: Marker): Triple<String?,String?,Boolean?>{
|
|
|
|
|
+ for (i in 0 until marker_list.size){
|
|
|
|
|
+ if(marker_list[i].marker==marker)
|
|
|
|
|
+ return Triple("$attr_URL/img/${marker_list[i].url}",
|
|
|
|
|
+ marker_list[i].description,
|
|
|
|
|
+ marker_list[i].downloaded)
|
|
|
|
|
+ }
|
|
|
|
|
+ return Triple(null,null,null)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // абстрактная функция класса InfoWindowAdapter - должна быть реализована в потомках
|
|
|
|
|
+ override fun getInfoWindow(marker: Marker): View? {
|
|
|
|
|
+ //тут менять только если меняется форма окна (круглое или облачко...)
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // в этой функции реализуется отрисовка контента, т.е. нашей формы
|
|
|
|
|
+ override fun getInfoContents(marker: Marker): View? {
|
|
|
|
|
+ // извлекаем кастомный layout
|
|
|
|
|
+ val inflater = applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
|
|
|
|
+ val view = inflater.inflate(R.layout.custom_infowindow, null)
|
|
|
|
|
+
|
|
|
|
|
+ // получаем урл и описание маркера
|
|
|
|
|
+ val (imgUrl, desc) = getImgUrl4Marker(marker)
|
|
|
|
|
+
|
|
|
|
|
+ val (imgUrl, desc, downloaded) = getImgUrl4Marker(marker)
|
|
|
|
|
+
|
|
|
|
|
+ if(imgUrl!=null) {
|
|
|
|
|
+ // Picasso кэширует картинки, если картинка уже была загружена, то callback не нужен
|
|
|
|
|
+ if (downloaded!!) {
|
|
|
|
|
+ Picasso.get().load(imgUrl).into(view.image)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // если новая картинка, то загружаем картинку с callback функцией
|
|
|
|
|
+ Picasso.get().load(imgUrl).into(view.image, InfoWindowRefresher(marker))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return view
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+4. Реализуем класс InfoWindowRefresher
|
|
|
|
|
+
|
|
|
|
|
+```kt
|
|
|
|
|
+ internal inner class InfoWindowRefresher : Callback {
|
|
|
|
|
+ private lateinit var markerToRefresh: Marker
|
|
|
|
|
+
|
|
|
|
|
+ // в конструкторе запоминаем маркер
|
|
|
|
|
+ constructor(marker: Marker) {
|
|
|
|
|
+ markerToRefresh = marker
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun onError(e: Exception?) {}
|
|
|
|
|
+
|
|
|
|
|
+ // по готовности ресурса перерисовываем информационное окно маркера
|
|
|
|
|
+ override fun onSuccess() {
|
|
|
|
|
+ for (i in 0 until marker_list.size){
|
|
|
|
|
+ if(marker_list[i].marker==markerToRefresh){
|
|
|
|
|
+ marker_list[i].downloaded = true
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ markerToRefresh.showInfoWindow()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+# Тестовый локальный сервер
|
|
|
|
|
+
|
|
|
|
|
+При работе в команде часто бывает, что код приложения уже более менее готов (можно отдавать тестерам), а сервера еще нет. Рассмотрим тестовый сервер, который можно быстро накидать на коленке для проверки API:
|
|
|
|
|
+
|
|
|
|
|
+> Пока написал на том, на чем умею - node.js. В перспективе перепишу на котлин
|
|
|
|
|
+
|
|
|
|
|
+1. Установите node.js (с официального сайта, все настройки по-умолчанию)
|
|
|
|
|
+
|
|
|
|
|
+2. Создайте каталог для сервера, перейдите в него и выполните команду (в командной строке операционной системы) ``npm init`` (на все вопросы жмите Enter). NPM - это менеджер пакетов, команда **init** создаст файл package.json - в нем хранятся зависимости, название главного файла проекта и т.п.
|
|
|
|
|
+
|
|
|
|
|
+3. В каталоге с проектом выполните команды (тоже в командной строке):
|
|
|
|
|
+
|
|
|
|
|
+``npm install express --save``
|
|
|
|
|
+
|
|
|
|
|
+и
|
|
|
|
|
+
|
|
|
|
|
+``npm install request --save``
|
|
|
|
|
+
|
|
|
|
|
+``npm install request-promise --save``
|
|
|
|
|
+
|
|
|
|
|
+Менеджер пакетов установит пакеты ``express`` (http-сервер) и ``request-promise`` (http-клиент), ключ ``--save`` запишет эти зависимости в файл проекта
|
|
|
|
|
+
|
|
|
|
|
+2. Создайте файл ``index.js`` (или то название, которое вы дали главному файлу проекта). В него скопируйте код сервера:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+// директива интерпретатору "строгий режим"
|
|
|
|
|
+'use strict';
|
|
|
|
|
+
|
|
|
|
|
+// импортируем библиотеки
|
|
|
|
|
+const express = require('express'),
|
|
|
|
|
+ request = require('request-promise');
|
|
|
|
|
+
|
|
|
|
|
+// создаем массив пользователей и массив достопримечательностей (достопримечательности можете добавить свои)
|
|
|
|
|
+var logged_users = [],
|
|
|
|
|
+ points = [
|
|
|
|
|
+ {"lat":56.63676, "lon":47.888929, "short":"Вечный огонь", "description":"Описание объекта Вечный огонь", "img":"fire.jpg"},
|
|
|
|
|
+ {"lat":56.631275, "lon":47.888787, "short":"Йошкин кот", "description":"Описание объекта Йошкин кот", "img":"cat.jpg"},
|
|
|
|
|
+ {"lat":56.631554, "lon":47.899335, "short":"Пушкин и Онегин", "description":"Описание объекта Пушкин и Онегин", "img":"onegin.jpg"},
|
|
|
|
|
+ {"lat":56.633858, "lon":47.900887, "short":"Простая еда", "description":"Описание объекта Простая еда", "img":"jumanji.jpg"}
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+//добавляю к консольному выводу дату и время
|
|
|
|
|
+function console_log(fmt, ...aparams){
|
|
|
|
|
+ fmt = (new Date()).toJSON().substr(0, 19)+' '+fmt;
|
|
|
|
|
+ console.log(fmt, ...aparams);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// генерирую случайное число для токена
|
|
|
|
|
+function getToken(){
|
|
|
|
|
+ return Math.ceil( Math.random()*9999999 )+1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// поиск пользователя в массиве по логину/паролю
|
|
|
|
|
+function getLoggedUser(login, password){
|
|
|
|
|
+ for (let index = 0; index < logged_users.length; index++) {
|
|
|
|
|
+ if(logged_users[index].login == login && logged_users[index].password==password){
|
|
|
|
|
+ return index;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// поиск пользователя по токену
|
|
|
|
|
+function getUserByToken(token){
|
|
|
|
|
+ for (let index = 0; index < logged_users.length; index++) {
|
|
|
|
|
+ if(logged_users[index].token == token){
|
|
|
|
|
+ return index;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// проверка пользователя при логине
|
|
|
|
|
+function checkUser(login, password){
|
|
|
|
|
+ let index = getLoggedUser(login, password);
|
|
|
|
|
+
|
|
|
|
|
+ if(index==null){
|
|
|
|
|
+ // новый юзер
|
|
|
|
|
+ let newUser = {login, password, token: getToken()};
|
|
|
|
|
+ console_log("Новый пользователь: login=%s, token=%s", login, newUser.token);
|
|
|
|
|
+
|
|
|
|
|
+ logged_users.push(newUser);
|
|
|
|
|
+ return newUser.token;
|
|
|
|
|
+ }else{
|
|
|
|
|
+ if(logged_users[index].token==0) {
|
|
|
|
|
+ //токена нет - генерим и возвращаем
|
|
|
|
|
+ logged_users[index].token = getToken();
|
|
|
|
|
+ console_log("Успешная авторизация: login=%s, token=%s", login, logged_users[index].token);
|
|
|
|
|
+ return logged_users[index].token;
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ console_log("Пользователь уже авторизован: login=%s", login);
|
|
|
|
|
+ throw new Error("User is active");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// создание экземпляра http-сервера
|
|
|
|
|
+var app = express();
|
|
|
|
|
+
|
|
|
|
|
+// метод .use задает команды, которые будут выполнены до разбора GET/POST команд
|
|
|
|
|
+
|
|
|
|
|
+// декодирует параметры запроса
|
|
|
|
|
+app.use( express.urlencoded() );
|
|
|
|
|
+
|
|
|
|
|
+// содержимое каталога img раздается статически по урлу /img/
|
|
|
|
|
+app.use('/img', express.static('img') );
|
|
|
|
|
+
|
|
|
|
|
+// логгирую все входящие запросы
|
|
|
|
|
+app.use((req, res, next)=>{
|
|
|
|
|
+ console_log('[express] %s request from %s, body: %s', req.path, req.ip, JSON.stringify(req.body));
|
|
|
|
|
+ next();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// POST запрос "логин"
|
|
|
|
|
+app.post('/login', (req,res)=>{
|
|
|
|
|
+ try {
|
|
|
|
|
+ // проверяем параметры запроса
|
|
|
|
|
+ if(req.body.login==undefined) throw new Error("В параметрах нет аттрибута login");
|
|
|
|
|
+ if(req.body.password==undefined) throw new Error("В параметрах нет аттрибута password");
|
|
|
|
|
+
|
|
|
|
|
+ // проверяем пользователя
|
|
|
|
|
+ let token = checkUser(req.body.login, req.body.password);
|
|
|
|
|
+
|
|
|
|
|
+ // если все нормально - возвращаем токен
|
|
|
|
|
+ res.json({notice: {token}});
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // при ошибке возвращаем текст ошибки
|
|
|
|
|
+ res.json({notice:{answer: error.message}});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // метод .end закрывает соединение
|
|
|
|
|
+ res.end();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// POST запрос "logout"
|
|
|
|
|
+app.post('/logout', (req,res)=>{
|
|
|
|
|
+ try {
|
|
|
|
|
+ if(req.body.login==undefined) throw new Error("В параметрах нет аттрибута login");
|
|
|
|
|
+ if(req.body.password==undefined) throw new Error("В параметрах нет аттрибута password");
|
|
|
|
|
+
|
|
|
|
|
+ let index = getLoggedUser(req.body.login, req.body.password);
|
|
|
|
|
+ if(index==null) throw new Error("пользователь не найден");
|
|
|
|
|
+ else logged_users[index].token = 0;
|
|
|
|
|
+
|
|
|
|
|
+ res.json({notice: {text: "user logout"}});
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ res.json({notice:{answer: error.message}});
|
|
|
|
|
+ }
|
|
|
|
|
+ res.end();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// POST запрос списка достопримечательностей
|
|
|
|
|
+app.post('/points', (req, res)=>{
|
|
|
|
|
+ try {
|
|
|
|
|
+ if(req.body.token==undefined) throw new Error("В параметрах нет аттрибута token");
|
|
|
|
|
+
|
|
|
|
|
+ let userIndex = getUserByToken(req.body.token);
|
|
|
|
|
+
|
|
|
|
|
+ if(userIndex==null) throw new Error("Пользователь с таким токеном не найден");
|
|
|
|
|
+
|
|
|
|
|
+ // в ответ пишем массив достопримечательностей
|
|
|
|
|
+ res.json( {status: "OK", points} );
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ res.json({notice:{answer: error.message}});
|
|
|
|
|
+ }
|
|
|
|
|
+ res.end();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// обработка запроса GET /directions - проксирование запроса расчета маршрута
|
|
|
|
|
+app.get('/directions', (req,res)=>{
|
|
|
|
|
+ try {
|
|
|
|
|
+ if(req.query.token==undefined) throw new Error("В параметрах нет аттрибута token");
|
|
|
|
|
+
|
|
|
|
|
+ let userIndex = getUserByToken(req.query.token);
|
|
|
|
|
+
|
|
|
|
|
+ if(userIndex==null) throw new Error("Пользователь с таким токеном не найден");
|
|
|
|
|
+
|
|
|
|
|
+ let url = "";
|
|
|
|
|
+
|
|
|
|
|
+ // все входящие параметры, кроме токена, передаем дальше
|
|
|
|
|
+ Object.keys(req.query).forEach(function(key) {
|
|
|
|
|
+ if(key!="token"){
|
|
|
|
|
+ if(url=="") url = "https://maps.googleapis.com/maps/api/directions/json?";
|
|
|
|
|
+ else url += '&';
|
|
|
|
|
+ url+=key+'='+req.query[key];
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ url += '&key=<тут должен быть ключ>';
|
|
|
|
|
+
|
|
|
|
|
+ console_log("Directions redirect: %s", url);
|
|
|
|
|
+
|
|
|
|
|
+ // http-запрос
|
|
|
|
|
+ request({
|
|
|
|
|
+ method: 'GET',
|
|
|
|
|
+ uri: url,
|
|
|
|
|
+ json: true
|
|
|
|
|
+ }).then(function (response) {
|
|
|
|
|
+ // Запрос был успешным, используйте объект ответа как хотите
|
|
|
|
|
+ console_log( JSON.stringify(response) );
|
|
|
|
|
+ res.json( response );
|
|
|
|
|
+ res.end();
|
|
|
|
|
+ }).catch(function (err) {
|
|
|
|
|
+ // Произошло что-то плохое, обработка ошибки
|
|
|
|
|
+ let ans = {notice: {answer: err}}
|
|
|
|
|
+ console_log( JSON.stringify(ans) );
|
|
|
|
|
+ res.json(and);
|
|
|
|
|
+ res.end();
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ let ans = {notice: {answer: error.message}}
|
|
|
|
|
+ console_log( JSON.stringify(ans) );
|
|
|
|
|
+ res.json( ans );
|
|
|
|
|
+ res.end();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// запуск сервера на порту 8080
|
|
|
|
|
+app.listen(8080, '0.0.0.0', ()=>{
|
|
|
|
|
+ console_log('HTTP сервер успешно запущен на порту 8080');
|
|
|
|
|
+}).on('error', (err)=>{
|
|
|
|
|
+ console_log('ошибка запуска HTTP сервера: %s', err)
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+# Проект **Погода** для электронных часов (Wear OS)
|
|
|
|
|
+
|
|
|
|
|
+1. Установите эмулятор для часов (будьте внимательны, не установите китайскую версию)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+2. Создайте проект:
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+3. Используя библиотеку **Fuel** напишите запрос погоды по названию города. На первом экране покажите температуру, облачность и кнопку "Подробнее". По клику на кнопке сделайте переход на второе окно (реализовать активити) с отображением подробной информации о погоде
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+[содержание](/readme.md)
|