Евгений Колесников 3 éve
szülő
commit
cc26b7cd82

+ 371 - 1
articles/f6_demo_1.md

@@ -16,6 +16,30 @@
         - [Сохранение состояния](#при-первом-запуске-приложения-первым-отображается-signup-screen-при-последующих---signin-01-балла)
     - [Экран авторизации](#экран-авторизации)
     - [Главный экран](#главный-экран)
+        - [Запрос подборки фильмов](#запрос-подборки-фильмов)
+        - [Скруглённые углы у постера](#скруглённые-углы-у-постера)
+        - [Переход между экранами](#переход-между-экранами)
+    - [Экран "профиль пользователя"](#экран-профиль-пользователя)
+        - [Запрос данных пользователя](#запрос-данных-пользователя)
+        - [Изменение аватарки пользователя](#изменение-аватарки-пользователя)
+            - [Выбор источника с помощью диалогового окна](#выбор-источника-камера-или-галерея-с-помощью-диалогового-окна)
+            - [Открытие Галереи](#открытие-галереи)
+            - [Отправка MultiPart запроса](#отправка-multipart-запроса)
+            - [Обрезка аватарки по размеру контейнера](#обрезка-аватарки-по-размеру-контейнера)
+            - [Кнопки "Обсуждения", "История", "Настройки" соответствуют макету](#кнопки-обсуждения-история-настройки-соответствуют-макету)
+    - [Экран списка чатов пользователя (Chat List Screen)](#экран-списка-чатов-пользователя-chat-list-screen)
+        - [Запрос списка чатов пользователя](#запрос-списка-чатов-пользователя)
+        - [Запрос сообщений чата](#запрос-сообщений-чата)
+        - [Отображение постера или аббревиатуры фильма](#отображение-постера-или-аббревиатуры-фильма)
+        - [Вывод двух строк в TextView](#вывод-двух-строк-в-textview)
+    - [Экран выбранного чата (Chat Screen)](#экран-выбранного-чата-chat-screen)
+        - [Упорядочивание (сортировка) списка](#упорядочивание-сортировка-списка)
+        - [Вывод даты перед группой сообщений](#вывод-даты-перед-группой-сообщений)
+        - [Группировка сообщений пользователя](#группировка-сообщений-пользователя)
+    - [Экран коллекций](#экран-коллекций)
+        - [Хранение информация о коллекциях в памяти устройства](#хранение-информация-о-коллекциях-в-памяти-устройства)
+        - [Список иконок плиткой](#список-иконок-плиткой)
+    - [Экран создания коллекций](#экран-создания-коллекций)
 
 * **Модуль 2**  - разработка приложения для часов (может быть и для телевизора). Коротенькое задание (30 минут). Нужно просто создать приложение из двух окон + работа с интернетом.
 * **Модуль 3**  - презентация.
@@ -453,11 +477,34 @@ Email проверяется на удовлетворение шаблону и
 
 Для вывода списка фильмов используйте компонент **RecyclerView** (направление пролистывания не указано, так что делайте как вам больше нравится)
 
+### Скруглённые углы у постера
+
+Есть вариант с программным изменением картинки, но он не учитывает последующую возможную обрезку картинки (понадобится при выводе аватарки). 
+
+Есть более простой и наглядный вариант: элемент **ImageView** заворачивается в контейнер **androidx.cardview.widget.CardView**, которому и задаётся радиус:
+
+```xml
+<androidx.cardview.widget.CardView
+    app:cardCornerRadius="100dp"
+>
+
+    <ImageView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+    />
+</androidx.cardview.widget.CardView>
+```
+
 ### Переход между экранами
 
 ![](../img/f6_015.png)
 
-Для перехода между экранами внизу экарана есть панель навигации. Для её реализации есть отдельныё механизм, который мы пока не рассматривали. Вы можете сюда поместить горизонтальный **LinearLayout**.
+Для перехода между экранами внизу главного экрана есть панель навигации. Для её реализации есть отдельный механизм, который мы пока не рассматривали. Вы можете сюда поместить горизонтальный **LinearLayout**.
+
+## Экран "профиль пользователя"
+
+![](../img/f6_016.png)
+
 
 >6. Реализуйте экран Profile Screen согласно макету:
 >   * Данные о пользователе необходимо запрашивать с сервера.
@@ -469,12 +516,251 @@ Email проверяется на удовлетворение шаблону и
 >   * При нажатии на кнопку "Выход" необходимо осуществлять переход на экран авторизации. 
 >   * При нажатии на кнопку «Обсуждения» необходимо переходить на соответствующий экран.
 
+Критерий | Баллы
+---------|:----:
+Изображения отображаются без искажений | 0.1
+Радиус скругления углов кнопки "Выход" соответствует макету | 0.1
+Реализован запрос информации о пользователе. Запрос фиксируется сервером | 0.5
+Реализована отправка аватара. Запрос фиксируется сервером | 1
+При получении ошибки от сервера она отображается | 0.3
+Аватар пользователя обрезается | 0.2
+Аватар пользователя загружается с сервера | 0.2
+Кнопки "Обсуждения", "История", "Настройки" соответствуют макету оценивается верстка): для каждой кнопки иконка, заголовок, стрелка (минус 0,2 за каждый отсутствующий, неполный или некорректный элемент) | 0.6
+Реализован выбор источника фотографии | 0.2
+**Итого** | 3.2
+
+## Запрос данных пользователя
+
+![](../img/f6_017.png)
+
+![](../img/f6_018.png)
+
+Если предыдущие запросы были доступны всем, то этот запрос может делать только авторизованный пользователь. 
+
+Для того, чтобы запрос был авторизованным, нужно в заголовок запроса добавить параметр *Authorization*, как описано в начале документа.
+
+Напоминаю синтаксис добавления заголовка в классе **http**:
+
+```kt
+Http.call(
+    Http.buildRequest(
+        "http://cinema.kolei.ru/user",
+        headers = mapOf("Authorization" to "Bearer ${app.token}")
+    ),
+    userCallback
+)
+```
+
+Успешный запрос вернёт **массив** объектов (с одним элементом, естественно), учитываёте при разборе JSON.
+
+Чтобы протестировать этот запрос в **swagger**-e, нужно получить токен авторизации запросом **/auth/login**, затем кликнуть кнопку **Authorize** на окне выше и ввести токен в поле **Value**:
+
+![](../img/f6_019.png)
+
+Иконка замка сменится на "закрыто". После этого можно выполнять запросы, требующие авторизации.
+
+В ответ на запрос информации о пользователе придёт что-то подобное:
+
+![](../img/f6_020.png)
+
+## Изменение аватарки пользователя
+
+### Выбор источника (камера или галерея) с помощью диалогового окна
+
+С диалоговыми окнами мы уже знакомы (мы их используем для вывода текста ошибок). У класса **AlertDialog** есть метод, позволяющий сделать выбор из массива:
+
+```kt
+// сначала объявляем массив строк для выбора
+val choiceItems = arrayOf("Галерея","Камера")
+
+// создаём и показываваем диалог
+AlertDialog.Builder(this)
+    .setTitle("Выберите источник")
+    .setNegativeButton("Отмена", null)
+    .setSingleChoiceItems(choiceItems, -1){
+        dialog, index ->
+        Toast.makeText(this, "$index", Toast.LENGTH_LONG).show()
+        dialog.dismiss()
+    }
+    .create()
+    .show()
+```
+
+При создании диалога добавился вызов метода *setSingleChoiceItems*, который как раз и задаёт массив элементов для выбора. **Первым** параметром этого метода задаётся массив **строк**, **вторым** - активный по-умолчанию элемент (можно указать `-1`, если не нужно выбирать что-то по-умолчанию) и **третьим** параметром задаётся лямбда функция, которая вызывается при выборе элемента списка. 
+
+В лямбда функцию передаётся два параметра: 
+* *dialog* - указатель на экземпляр диалога
+* *index* - позиция выбранного элемента в массиве
+
+Я в примере выше просто вывожу на экран номер выбранного элемента и закрываю диалог (*dialog.dismiss()*). Вам нужно в зависимости от выбранного элемента либо показать Галерею, либо открыть приложение Камера (можно прямо тут, но можно и выделить в отдельную лямбда функцию).
+
+### Открытие Галереи
+
+Открытие внешних программ (активностей) осуществляется тем же вызовом класса **Intent**. Только в параметрах мы должны передавать не контекст (*this*), а тип нужной нам активности.
+
+Для получения каких-то данных используется тип **Intent.ACTION_PICK**.
+
+Для конкретизации типа данных указывается свойство *type*
+
+Для открытия активности, которая может вернуть список картинок:
+
+```kt
+// константа для анализа результата объявляется на уровне класса
+val GALLERY_REQUEST = 1
+
+val photoPickerIntent = Intent(Intent.ACTION_PICK)
+// фильтр
+photoPickerIntent.type = "image/jpg"
+
+//запускаем запрос, указав что ждём результат 
+startActivityForResult(photoPickerIntent, GALLERY_REQUEST)
+```
+
+Получение результата от активности мы уже разбирали, когда делали выбор города в проекте "погода". Обработчик результата всего один для класса активности. Поэтому при запуске активности мы и передаём уникальный номер запроса, чтобы при разборке ответа знать от кого он пришёл:
+
+```kt
+override fun onActivityResult(
+    requestCode: Int, 
+    resultCode: Int, 
+    intent: Intent?) 
+{
+    // проверяем код запроса
+    if (requestCode == GALLERY_REQUEST) {
+        // убеждаемся что выполнено успешно (пользователь мог ничего и не выбрать в галерее)
+        if (resultCode == Activity.RESULT_OK  && intent != null) {
+            // галерея возвращает URI картинки в свойстве data параметра intent
+            sendFile(intent.data!!)
+        }
+    }
+}
+```
+
+### Отправка MultiPart запроса 
+
+>Класс **StreamHelper**, используемый при получении файла, лежит в [шпаргалках](../shpora/StreamHelper.kt)
+
+```kt
+private fun sendFile(fileUri: Uri) {
+    // получаем из Uri файла указатель на поток данных
+    val fileStream = contentResolver
+        .openInputStream(fileUri)
+
+    // получаем файл
+    val fileBody: RequestBody = StreamHelper
+        .create(
+            "image/jpg".toMediaType(), 
+            fileStream!!)
+
+    val requestBody = MultipartBody.Builder()
+        .setType(MultipartBody.FORM)
+        .addFormDataPart("token", app.token)
+        .addFormDataPart(
+            "file", // название части запроса
+            "filename.jpg", // имя файла
+            fileBody    // тело файла
+        )
+        .build()
+
+    val request = Request.Builder()
+        .url("http://cinema.kolei.ru/user/avatar")
+        .post(requestBody)
+        .build()
+
+    Http.call(request) { response, error ->
+        try {
+            if (error != null) throw error
+            if (!response!!.isSuccessful) 
+                throw Exception(response.message)
+
+            // всё ОК
+            runOnUiThread {
+                /* при успешной отправке меняем аватарку 
+                (ImageView в вашей активности)*/
+                avatarImageView.setImageURI( fileUri )
+            }
+        } catch (e: Exception) {
+            showAlert(e.message!!)
+        }
+    }
+}
+```
+
+### Обрезка аватарки по размеру контейнера
+
+При выборе файла из галереи пропорции будут скорее всего не одинаковые, поэтому при отображении вы должны задать как отображается изображение:
+
+```xml
+<ImageView
+    android:layout_width="88dp"
+    android:layout_height="88dp"
+    android:adjustViewBounds="true"
+    android:scaleType="centerCrop"
+```
+
+Параметр `android:adjustViewBounds="true"` включает масштабирование, а параметр `android:scaleType` указывает тип масштабирования. Вариантов масштабирования несколько, вы можете посмотреть на практике как они влияют на результат. Но нам нужен *centerCrop*, означающий, что картинка будет центрироваться, а то, что не помещается отрежется.
+
+### Кнопки "Обсуждения", "История", "Настройки" соответствуют макету
+
+Тут всё просто: в горизонтальный **LinearLayout** помещаете три элемента, как в вёрстке, задаёте этому **LinearLayout**-у тег `id` и событие *setOnClickListener* "вешаете" на **LinearLayout**. (Событие *setOnClickListener* объявлено в базовом классе **View**, от которого наследуются все визуальные элементы)
+
+## Экран списка чатов пользователя (Chat List Screen)
+
+![](../img/f6_021.png)
+
 >7. Реализуйте экран Chat List Screen согласно макету:
 >   * Информацию необходимо запрашивать с сервера (запрос чатов пользователя). Если информация с сервера содержит дубляжи – их необходимо удалить. Если ваш пользователь еще не имеет чатов, сервер присылает пустой список, отправьте сообщение от текущего пользователя в чате с id = 1 с помощью Swagger или Postman.
 >   * В подзаголовке ячейки необходимо отобразить последнее сообщение в соответствующем чате + имя его автора. Текст сообщения необходимо обрезать до двух строк.
 >   * Реализуйте отображение постеров к фильмам. Для получения постеров используйте подходящий запрос из API. Если постер для данного фильма нельзя получить из API - сгенерируйте абревиатуру по следующему правилу: если название фильма состоит и одного слова - необходимо взять первые две буквы слова; иначе - первые буквы первого и второго слова.
 >   * При нажатии на ячейку необходимо осуществлять переход на Chat Screen для выбранного фильма.
 
+Критерий | Баллы
+---------|:----:
+Реализован запрос списка чатов пользователя | 0.5
+Реализован запрос сообщений чата | 0.5
+Ячейка таблицы соответствует макету (оценивается верстка).Корректно реализованы 3 элемента: изображение, название, подзаголовок (минус 0,1 за каждый отсутствующий или
+некорректный элемент). | 0.3
+В подзаголовке отображается последнее сообщение
+для данного чата | 0.2
+Текст сообщения обрезается до двух строк | 0.2
+Реализовано отображение аббревиатуры согласно Заданию | 0.3
+**Итого** | 2
+
+### Запрос списка чатов пользователя
+
+Нужно учитывать, что запрос требует авторизации, а в остальном ничего особенного. 
+
+>Учитывая, что в информации о чате мы должны показать последнее сообщение из чата, в дата класс надо добавить и свойство для этого сообщения
+
+### Запрос сообщений чата
+
+В информации о чате мы должны показать последнее сообщение этого чата, поэтому после получения списка чатов мы должны по каждому из них запросить и список сообщений этого чата. Последнее сообщение (тут желательно сделать проверку по дате) этого списка вписать в элемент списка чатов (ищем по Id чата)
+
+`GET {{base_url}}/chats/{{chatId}}/messages`
+
+### Отображение постера или аббревиатуры фильма
+
+Для вывода постера нужно иметь список фильмов. Можно запросить его ещё раз, а можно в главном окне хранение фильмов сделать не в свойствах класса активности, а в свойствах класса приложения (там, где вы храните токен)
+
+<!-- TODO сделать аббревиатуру названия фильма -->
+
+### Вывод двух строк в TextView
+
+Просто добавить атрибут *lines*:
+
+```xml
+<TextView
+    android:lines="2"
+    ...
+```        
+
+### Выход из экрана
+
+По клику на стрелку влево (в верхней строке окна) вызвать метод *finish()* активности.
+
+## Экран выбранного чата (Chat Screen)
+
+![](../img/f6_022.png)
+
 >8. Реализуйте экран Chat Screen согласно макету:
 >   * Сообщения необходимо упорядочить от старых к новым сверху вниз. Для сегодняшних сообщений необходимо отобразить заголовок "Сегодня".
 >   * "Облако" сообщения должно растягиваться по содержимому.
@@ -483,14 +769,98 @@ Email проверяется на удовлетворение шаблону и
 >   * При нажатии на кнопку "Отправить" необходимо отправить сообщение на сервер. При позитивном ответе от сервера необходимо отобразить сообщение в чате. При возникновении ошибки - отобразить ошибку с помощью диалогового окна.
 >   * Необходимо валидировать поле для ввода на пустоту. При отсутствии текста сообщения необходимо отобразить ошибку с помощью диалогового окна.
 
+Критерий | Баллы
+---------|:----:
+Реализован запрос сообщений чата | 0.5
+Реализована отправка сообщения в чат | 0.7
+Отображение сообщения соответствует макету (оценивается верстка). Корректно реализованы 3 элемента: аватар, текст, подзаголовок (минус 0,1 за каждый отсутствующий или некорректный элемент) | 0.3
+Сообщения упорядочены согласно Конкурсному Заданию | 0.3
+"Облако" сообщения растягивается по содержимому без искажений радиуса скругления углов | 0.4
+Отображение последовательно идущих сообщений одного автора соответствует макету (расстояния между сообщениями меньше) | 0.4
+Поле для ввода растягивается при добавлении текста | 0.4
+При получении ошибки от сервера она отображается с помощью диалогового окна | 0.3
+При попытке отправить пустое сообщение отображается ошибка с помощью диалогового окна | 0.3
+**Итого** | 3.6
+
+Как получить список сообщений мы уже разбирали в прошлой лекции.
+
+### Упорядочивание (сортировка) списка
+
+Для того, чтобы массив объектов (экземпляров какого-либо класса) можно было сортировать, класс должен реализовывать интерфейс **Comparable** и переопределить метод *compareTo*:
+
+```kt
+data class ChatMessage(
+    val chatId: String,
+    val messageId: String,
+    val creationDateTime: LocalDateTime
+    // тут остальные поля
+    ): Comparable<ChatMessage>
+{
+    override fun compareTo(other: ChatMessage): Int {
+        return if(other.creationDateTime > this.creationDateTime) 1 else -1
+    }
+}
+
+...
+
+// после заполнения списка сообщений просто вызвать метод sort()
+chatMessageList.sort()
+```
+
+### Вывод даты перед группой сообщений
+
+1. В вёрстке элемента сообщения должен быть элемент для даты
+2. В адаптере **RecyclerView** сравнивать дату текущего сообщения с датой предыдущего (учитывайте, что предыдущего может не быть). Если дата отличается, то выводим дату, если не отличатется, то скрываем элемент даты (`visible = View.GONE`)
+
+### Группировка сообщений пользователя
+
+В принципе тоже самое что и с датой, только менять верхнюю границу сообщения
+
+```kt
+// считываем текущие параметры разметки элемента
+val param = messageTextView.layoutParams as ViewGroup.MarginLayoutParams
+
+// устанавливаем нужную границу
+param.setMargins(10,10,10,10)
+
+// применяем изменившиеся параметры к элементу
+messageTextView.layoutParams = param
+```
+
+## Экран коллекций
+
+![](../img/f6_024.png)
+
 >9. Реализуйте экран Collections Screen согласно макету:
 >   * При нажатии на иконку в правом верхнем углу необходимо переходить на экран Create Collection Screen.
 >   * На экране необходимо отображать созданные коллекции. Информация о коллекциях должна храниться в памяти устройства. Необходимо хранить название коллекции и иконку.
 >   * Реализуйте Swipe-to-delete для удаления коллекции, в том числе из памяти устройства.
 
+Критерий | Баллы
+---------|:----:
+Ячейка коллекции соответствует макету (оценивается верстка). Корректно реализованы 3 элемента: иконка, название, стрелка (минус 0,1 за каждый отсутствующий или некорректный элемент) | 0.3
+Реализована возможность удаления коллекции с помощью swipe-to-delete | 0.3
+**Итог** | 0.6
+
+### Хранение информация о коллекциях в памяти устройства
+
+Вспоминаем метод *getSharedPreferences* - список коллекций можно хранить JSON-строкой (напоминаю, что хранилище оперирует скалярными типами). В качестве идентификатора иконки использовать Id ресурса.
+
+### Список иконок плиткой
+
+https://developer.android.com/codelabs/kotlin-android-training-grid-layout#0
+
+https://github.com/google-developer-training/android-kotlin-fundamentals-apps/tree/master/RecyclerViewGridLayout
+
+## Экран создания коллекций
+
+![](../img/f6_023.png)
+
 >10. Реализуйте экран Create Collection Screen согласно макету:
 >   * При открытии экрана в качестве иконки должно быть выбрано случайное изображение из коллекции иконок.
 >   * При нажатии на кнопку "Выбрать иконку" необходимо осуществлять переход на экран Icon Selection. Реализуйте данный экран в соответствии с макетом.
 >   * При нажатии на кнопку "Сохранить" необходимо сохранить новую коллекцию в памяти устройства и закрыть экран.
 >   * Необходимо проверять на пустоту поле для ввода названия коллекции. При отсутствии значения необходимо отобразить сообщение об ошибке.
 
+Критерий | Баллы
+---------|:----:

+ 143 - 1
cinema/index.js

@@ -1,7 +1,10 @@
 'use strict'
 
 const express = require('express')
+const fileUpload = require('express-fileupload')
 var cors = require('cors')
+const md5 = require('md5')
+const fs = require('fs')
 
 //добавляю к консольному выводу дату и время
 function console_log(fmt, ...aparams){
@@ -17,6 +20,7 @@ const app = express()
 // декодирует параметры запроса
 app.use( express.urlencoded() )
 app.use( express.json() )
+app.use(fileUpload())
 
 app.use('/up/images', cors(), express.static(__dirname +'/images') )
 app.use('/swagger', cors(), express.static(__dirname +'/swagger') )
@@ -41,6 +45,14 @@ const movies = [
   {movieId: 10, name: 'Паранормальные явления. Дом призраков', age: '16', images: [], poster: 'Paranormal.webp', tags: [], filters: ['new','inTrend','forMe'], description: 'Когда-то Шон был популярным видеоблогером, сделавшим имя на экстремальных роликах, в которых он бросал вызов собственным страхам, но однажды вляпался в скандал и потерял всех спонсоров. Записав видео с извинениями и снова получив финансирование, парень возвращается с новым леденящим душу проектом. Шон собирается провести ночной стрим из дома с привидениями, где более 100 лет назад повесилась одинокая женщина, а после неоднократно фиксировалась паранормальная активность.'}
 ]
 
+const chats = [
+  {chatId: '1', name: 'Всё о дюне'},
+  {chatId: '2', name: 'Кто такой зелёный рыцарь?'},
+  {chatId: '3', name: 'Петр первый: великий император или разрушитель руси'}
+]
+
+const chatMessages = []
+
 function findUserByEmail(email) {
   for (let i = 0; i < registeredUsers.length; i++) {
     if (registeredUsers[i].email == email) 
@@ -89,7 +101,8 @@ app.post('/auth/register', cors(), (req,res)=>{
         password: req.body.password,
         firstName: req.body.firstName,
         lastName: req.body.lastName,
-        token: token
+        token: token,
+        avatar: ''
       })
     }
 
@@ -134,6 +147,7 @@ function checkAuth(req){
       let user = findUserByToken(parts[1])
       if (user == null)
         throw new Error('User not found')
+      return user
     } else
       throw new Error('Unsupported Authorization method')
   } else
@@ -169,6 +183,134 @@ app.get('/movies', cors(), (req,res)=>{
   res.end()
 })
 
+function userModel (user) {
+  const fileName = md5(user.email)+'.jpg'
+  const userObj = {
+    userId: user.token,
+    firstName: user.firstName,
+    lastName: user.lastName,
+    email: user.email,
+    avatar: user.avatar
+  }
+  if (fs.existsSync(__dirname + '/images/'+ fileName)) 
+    userObj.avatar = fileName
+
+  return [userObj]
+}
+
+app.options('/user', cors())
+app.get('/user', cors(), (req,res)=>{
+  try {
+    let user = checkAuth(req)
+    res.json(userModel(user))
+  } catch (error) {
+    res.statusMessage = error.message
+    res.status(401)
+  }
+  res.end()
+})
+
+app.options('/user/chats', cors())
+app.get('/user/chats', cors(), (req,res)=>{
+  try {
+    checkAuth(req)
+    res.json(chats)
+  } catch (error) {
+    res.statusMessage = error.message
+    res.status(401)
+  }
+  res.end()
+})
+
+function getChatMessage(message, user) {
+  return {
+    chatId: message.chatId,
+    messageId: message.messageId,
+    creationDateTime: message.creationDateTime,
+    firstName: user.firstName,
+    lastName: user.lastName,
+    avatar: user.avatar,
+    text: message.text
+  }
+}
+
+app.options('/chats/:chatId/messages', cors())
+app.get('/chats/:chatId/messages', cors(), (req,res)=>{
+  try {
+    checkAuth(req)
+    let messages = []
+    // console_log('try get chat messages for chatId: %s', req.params.chatId)
+    for (let i = 0; i < chatMessages.length; i++) {
+      if(chatMessages[i].chatId == req.params.chatId) {
+        let user = findUserByToken(chatMessages[i].userId)
+        if(user != null) {
+          let chatMessage = getChatMessage(chatMessages[i], user)
+          messages.push(chatMessage)
+        }
+      }
+    }
+    res.json(messages)
+  } catch (error) {
+    res.statusMessage = error.message
+    res.status(401)
+  }
+  res.end()
+})
+
+function dateToMysql(xDate) {
+  return xDate.getFullYear().toString(10)
+      + '-' + (xDate.getMonth()+1).toString(10).padStart(2,'0')
+      + '-' + xDate.getDate().toString(10).padStart(2,'0')
+      + ' ' + xDate.getHours().toString(10).padStart(2,'0')
+      + ':' + xDate.getMinutes().toString(10).padStart(2,'0')  
+}
+
+app.post('/chats/:chatId/messages', cors(), (req,res)=>{
+  try {
+    let user = checkAuth(req)
+    let newMessage = {
+      chatId: req.params.chatId,
+      messageId: chatMessages.length + 1,
+      creationDateTime: dateToMysql(new Date()),
+      userId: user.token,
+      text: req.body.text
+    }
+    chatMessages.push(newMessage)
+    res.json(getChatMessage(newMessage, user))
+  } catch (error) {
+    res.statusMessage = error.message
+    res.status(401)
+  }
+  res.end()
+})
+
+app.options('/user/avatar', cors())
+app.post('/user/avatar', cors(), (req, res) => {
+  try {
+    // console.log(req.files)
+
+    if(req.body.token==undefined) 
+      throw new Error('Not found "token" param in body')
+
+    const user = findUserByToken(req.body.token)
+    if(!user) throw new Error('User not found')
+
+    const { file } = req.files
+    if (!file) throw new Error('No file in request')
+
+    const fileName = md5(user.email)+'.jpg'
+
+    // console_log('try save avatar: %s', fileName)
+
+    file.mv(__dirname + '/images/' + fileName)
+    res.json(userModel(user))
+  } catch (error) {
+    res.statusMessage = error.message
+    res.status(400)
+  }
+  res.end()
+})
+
 // запуск сервера на порту 8080
 app.listen(3019, '0.0.0.0', ()=>{
     console_log('HTTP сервер успешно запущен на порту 3019')

+ 46 - 0
cinema/package-lock.json

@@ -37,6 +37,14 @@
         "unpipe": "1.0.0"
       }
     },
+    "busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "requires": {
+        "streamsearch": "^1.1.0"
+      }
+    },
     "bytes": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -51,6 +59,11 @@
         "get-intrinsic": "^1.0.2"
       }
     },
+    "charenc": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+      "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
+    },
     "content-disposition": {
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -83,6 +96,11 @@
         "vary": "^1"
       }
     },
+    "crypt": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+      "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
+    },
     "debug": {
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -159,6 +177,14 @@
         "vary": "~1.1.2"
       }
     },
+    "express-fileupload": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.4.0.tgz",
+      "integrity": "sha512-RjzLCHxkv3umDeZKeFeMg8w7qe0V09w3B7oGZprr/oO2H/ISCgNzuqzn7gV3HRWb37GjRk429CCpSLS2KNTqMQ==",
+      "requires": {
+        "busboy": "^1.6.0"
+      }
+    },
     "finalhandler": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
@@ -241,6 +267,21 @@
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
       "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
     },
+    "is-buffer": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+    },
+    "md5": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+      "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+      "requires": {
+        "charenc": "0.0.2",
+        "crypt": "0.0.2",
+        "is-buffer": "~1.1.6"
+      }
+    },
     "media-typer": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -413,6 +454,11 @@
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
       "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="
     },
+    "streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
+    },
     "toidentifier": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

+ 3 - 1
cinema/package.json

@@ -10,6 +10,8 @@
   "license": "ISC",
   "dependencies": {
     "cors": "^2.8.5",
-    "express": "^4.18.2"
+    "express": "^4.18.2",
+    "express-fileupload": "^1.4.0",
+    "md5": "^2.3.0"
   }
 }

+ 192 - 3
cinema/swagger/cinema.yml

@@ -27,6 +27,8 @@ tags:
     description: Информация о киноновинках
   - name: up
     description: Ресурсы
+  - name: user
+    description: Информация о пользователе
 
 paths:
   /auth/register:
@@ -133,6 +135,134 @@ paths:
                 $ref: '#/components/schemas/Movie'
         '400':
           description: Проблемы при запросе
+  /user:
+    get:
+      tags: 
+        - user
+      summary: Получить информацию о пользователе
+      description: Необходимо передать header параметр авторизации типа Bearer
+      security:
+        - BearerAuth: []
+      responses:
+        '200':
+          description: Данные пользователя
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/User'
+        '401':
+          description: Неавторизированный доступ
+  /user/avatar:
+    post:
+      tags:
+        - user
+      security:
+        - BearerAuth: []
+      summary: Загрузка фотографии
+      description: Данный запрос принимает только изображения формата .jpg/.jpeg. Пустые изображения и невалидные изображения не разрешены
+      requestBody:
+        required: true
+        content: 
+          multipart/form-data:
+            schema:
+              type: object
+              properties:
+                token:
+                  type: string
+                  format: string
+                file:
+                  type: string
+                  format: binary
+            encoding:
+              file:
+                contentType: image/jpg, image/jpeg
+      responses:
+        '200':
+          description: Данные пользователя
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/User'
+        '401':
+          description: Неавторизированный доступ
+  /user/chats:
+    get:
+      tags:
+        - user
+      security:
+        - BearerAuth: []
+      summary: Список чатов, в которых учавствует данный пользователь
+      description: Необходимо передать header параметр авторизации типа Bearer
+      responses:
+        '200':
+          description: Массив информации о чатах пользователя
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Chat'
+        '401':
+          description: Неавторизованный доступ
+  /chats/{chatId}/messages:
+    get:
+      tags:
+        - user
+      security:
+        - BearerAuth: []
+      summary: Список сообщений чата
+      description: Необходимо передать header параметр авторизации типа Bearer
+      parameters:
+        - in: path
+          name: chatId
+          schema:
+            type: integer
+          required: true
+          description: Id чата
+      responses:
+        '200':
+          description: Массив сообщений чата
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Message'
+        '401':
+          description: Неавторизированный доступ
+    post:
+      tags:
+        - user
+      security:
+        - BearerAuth: []
+      summary: Отправить сообщение в чат
+      description: Необходимо передать header параметр авторизации типа Bearer
+      parameters:
+        - in: path
+          name: chatId
+          schema:
+            type: integer
+          required: true
+          description: Id чата
+      requestBody:
+        required: true
+        content: 
+          application/json:
+            schema:
+              type: object
+              properties:
+                text:
+                  type: string
+                  example: Текст сообщения
+      responses:
+        '200':
+          description: Сообщение чата
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Message'
+        '400':
+          description: Проблемы при сохранении
+        '401':
+          description: Неавторизированный доступ
   /up/images/{imageName}:
     get:
       tags:
@@ -147,9 +277,9 @@ paths:
           description: Название картинки (с расширением), полученное при запросе списка фильмов
       responses:
         '200':
-          description: Файл картинки в произвольном формате
+          description: Файл картинки в формате .jpg
           content:
-            image/png:
+            image/jpg:
               schema:
                 type: string
                 format: binary
@@ -202,4 +332,63 @@ components:
           tagName:
             type: string
             example: Комедия
-
+    User:
+      type: array
+      items:
+        type: object
+        properties:
+          userId:
+            type: string
+            example: 27
+          firstName:
+            type: string
+            example: Евгений
+          lastName:  
+            type: string
+            example: Колесников
+          email:
+            type: string
+            example: kolei@yandex.ru
+          avatar:
+            type: string
+            example: ekolesnikov.jpg
+            description: Название файла
+    Chat:
+      type: array
+      items:
+        type: object
+        properties:
+          name:
+            type: string
+            description: Название чата
+            example: Дюна
+          chatId: 
+            type: string
+            example: 1
+    Message:
+      type: object
+      properties:
+        chatId:
+          type: string
+          example: 1
+        messageId: 
+          type: string
+          example: 1
+        creationDateTime:
+          type: string
+          format: date
+          example: 2022-11-07 10:30
+          description: Дата и время добавления сообщения в формате YYYY-MM-DD hh:mm
+        firstName:
+          type: string
+          example: Евгений
+        lastName:
+          type: string
+          example: Колесников
+        avatar:
+          type: string
+          example: somename.jpg
+        text:
+          type: string
+          example: А мне не понравился последний сезон. Позор создателям.
+        

BIN
img/f6_016.png


BIN
img/f6_017.png


BIN
img/f6_018.png


BIN
img/f6_019.png


BIN
img/f6_020.png


BIN
img/f6_021.png


BIN
img/f6_022.png


BIN
img/f6_023.png


BIN
img/f6_024.png


+ 45 - 0
shpora/StreamHelper.kt

@@ -0,0 +1,45 @@
+// тут должен быть ван package
+
+import android.os.Environment
+import android.util.Log
+import okhttp3.MediaType
+import okhttp3.RequestBody
+import okhttp3.internal.and
+import okhttp3.internal.closeQuietly
+import okio.BufferedSink
+import okio.Source
+import okio.source
+import java.io.*
+
+
+/**
+ * Created by yuanxin on 1/30/2018.
+ */
+object StreamHelper {
+    fun create(mediaType: MediaType?, inputStream: InputStream): RequestBody {
+        return object : RequestBody() {
+            override fun contentType(): MediaType? {
+                return mediaType
+            }
+
+            override fun contentLength(): Long {
+                return try {
+                    inputStream.available().toLong()
+                } catch (e: IOException) {
+                    0
+                }
+            }
+
+            @Throws(IOException::class)
+            override fun writeTo(sink: BufferedSink) {
+                var source: Source? = null
+                try {
+                    source = inputStream.source()
+                    sink.writeAll(source)
+                } finally {
+                    source!!.closeQuietly()
+                }
+            }
+        }
+    }
+}