3. Приклад із практики — Управління зустрічами
3.1. Проєкт
У документі [Tutoriel AngularJS / Spring 4] було розроблено клієнт-серверний додаток для управління зустрічами з лікарями. Надалі ми будемо посилатися на цей документ як [rdvmedecins-angular]. Додаток мав два типи клієнтів:
- клієнт HTML / CSS / JS;
- клієнт для Android;
Клієнт для Android отримувався автоматично на основі версії клієнта HTML за допомогою інструменту [Cordova]. Мета цього проекту — вручну відтворити цей клієнт для Android, використовуючи знання, отримані в попередніх розділах.
Слід зазначити важливу відмінність між цими двома рішеннями:
- той, який ми створимо, працюватиме лише на планшетах Android;
- у версії [rdvmedecins-angular] мобільний веб-клієнт (HTML / CSS / JS) працює на будь-якій платформі (Android, IoS, Windows);
3.2. Види клієнта для Android
Є чотири екрани.
Екран налаштувань

Екран вибору лікаря та дати прийому

Екран вибору часового проміжку для прийому

Вигляд екрану вибору клієнта для прийому

3.3. Архітектура проекту
Архітектура клієнт/сервер буде аналогічною до тієї, що наведена у прикладі [Exemple-15] (див. розділ 1.16) цього документа:

Асинхронний обмін даними між клієнтом і сервером буде здійснюватися за допомогою бібліотеки RxAndroid.
3.4. База даних
Вона не відіграє ключової ролі в цьому документі. Ми наводимо її лише для довідки. Назвемо її [dbrdvmedecins] . Це база даних MySQL5 із чотирма таблицями:
![]() |
3.4.1. Таблиця [MEDECINS]
Вона містить інформацію про лікарів, які обслуговуються додатком [RdvMedecins].
![]() | ![]() |
- ID: номер, що ідентифікує лікаря — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 щоразу, коли до рядка вносяться зміни.
- NOM: прізвище лікаря
- PRENOM: його ім’я
- TITRE: його/її титул (пані, пані, пан)
3.4.2. Таблиця [CLIENTS]
Пацієнти різних лікарів заносяться до таблиці [CLIENTS]:
![]() | ![]() |
- ID: номер, що ідентифікує клієнта — первинний ключ таблиці
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 кожного разу, коли до рядка вносяться зміни.
- NOM: ім’я клієнта
- PRENOM: його ім’я
- TITRE: його/її титул (пані, пані, пан)
3.4.3. Таблиця [CRENEAUX]
У ній перелічено часові проміжки, у яких можливі RV:
![]() |
![]() | ![]() | ![]() |
- ID: номер, що ідентифікує часовий проміжок — первинний ключ таблиці (рядок 8)
- VERSION: номер, що ідентифікує версію рядка в таблиці. Це число збільшується на 1 кожного разу, коли до рядка вносяться зміни.
- ID_MEDECIN: номер, що ідентифікує лікаря, якому належить цей часовий проміжок — зовнішній ключ у стовпці MEDECINS (ID).
- HDEBUT: час початку часового проміжку
- MDEBUT: хвилини початку часового проміжку
- HFIN: година закінчення часового проміжку
- MFIN: хвилини закінчення інтервалу
Другий рядок таблиці [CRENEAUX] (див. [1] вище) вказує, наприклад, що слот № 2 починається о 8:20 і закінчується о 8:40 та належить лікарю № 1 (пані Марі PELISSIER).
3.4.4. Таблиця [RV]
У ній наведено перелік RV, призначених кожному лікарю:
![]() | ![]() |
- ID: номер, що однозначно ідентифікує RV — первинний ключ
- JOUR: день RV
- ID_CRENEAU: часовий проміжок запису RV — зовнішній ключ до поля [ID] у таблиці [CRENEAUX] — визначає як часовий проміжок, так і відповідного лікаря.
- ID_CLIENT: номер клієнта, для якого зроблено бронювання — зовнішній ключ у полі [ID] таблиці [CLIENTS]
Ця таблиця має обмеження унікальності ( ) для значень з’єднаних стовпців (JOUR, ID_CRENEAU):
Якщо рядок таблиці [RV] має значення (JOUR1, ID_CRENEAU1) для стовпців (JOUR, ID_CRENEAU), це значення не може зустрічатися більше ніде. Інакше це означало б, що одночасно було зареєстровано два записи RV для одного й того самого лікаря. З точки зору програмування на Java драйвер JDBC бази даних запускає SQLException, коли трапляється такий випадок.
Рядок id, що дорівнює 3 (див. [1] вище), означає, що 23.08.2006 було заброньовано RV для слоту № 20 та клієнта № 4. З таблиці [CRENEAUX] випливає, що слот № 20 відповідає часовому проміжку 16:20–16:40 і належить лікарю № 1 (пані Марі PELISSIER). З таблиці [CLIENTS] випливає, що клієнт № 4 — це пані Бріжит BISTROU.
3.4.5. Створення бази даних
Для створення таблиць та їх заповнення можна використати скрипт [dbrdvmedecins.sql], який знаходиться в архіві прикладів |ICI|.
![]() |
За допомогою [WampServer] (див. розділ 6.15) можна діяти наступним чином:
![]() | ![]() |
- у [1] натисніть на піктограму [WampServer] і виберіть опцію [PhpMyAdmin] [2],
- у [3] у вікні, що відкрилося, виберіть посилання [Bases de données],
![]() |
- на [4-6], імпортуємо файл SQL,
![]() | ![]() | ![]() |
- у [7] вибираємо скрипт SQL, а в [8] запускаємо його,
- у [9] таблиці бази даних вже створено. Переходимо за одним із посилань,
![]() |
- у [10] — вміст таблиці.
Надалі ми більше не повертатимемося до цієї бази даних, але читачеві пропонується стежити за її розвитком у ході тестування, особливо коли додаток не працює.
3.5. Веб-сервер / jSON

Тут нас цікавить сервер [1]. Ми не будемо його детально розглядати. Він детально описаний у документі [Spring MVC et Thymeleaf par l'exemple]. Зацікавлений читач може звернутися до нього. Він розроблений так само, як і сервер із прикладу 15. Його вихідний код наведено у прикладах. Тут ми будемо використовувати його бінарний файл:
![]() |
- [rdvmedecins-server-all-1.0.jar] — це бінарний файл сервера;
3.5.1. Впровадження
У командному рядку перейдіть до папки, що містить бінарний файл сервера:
...\rdvmedecins>dir
Le volume dans le lecteur D s’appelle Données
Le numéro de série du volume est 7A34-AE5F
Répertoire de D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins
09/06/2016 10:50 <DIR> .
09/06/2016 10:50 <DIR> ..
06/07/2014 16:36 7 631 dbrdvmedecins.sql
08/06/2016 16:31 <DIR> rdvmedecins-client
08/06/2016 16:22 <DIR> rdvmedecins-server
08/06/2016 16:23 29 618 709 rdvmedecins-server-all-1.0.jar
потім, щоб запустити сервер, введіть таку команду (SGBD та MySQL мають бути вже запущені):
...\rdvmedecins>java -jar rdvmedecins-server-all-1.0.jar
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v1.0)
10:55:48.617 [main] INFO rdvmedecins.boot.Boot - Starting Boot v1.0 on st-PC (D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins\rdvmedecins-server-all-1.0.jar started by st in D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins)
10:55:48.621 [main] INFO rdvmedecins.boot.Boot - No active profile set, falling back to default profiles: default
10:55:48.662 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@7085bdee: startup date [Thu Jun 09 10:55:48 CEST 2016]; root of context hierarchy
10:55:49.948 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
juin 09, 2016 10:55:50 AM org.apache.catalina.core.StandardService startInternal
INFOS: Starting service Tomcat
juin 09, 2016 10:55:50 AM org.apache.catalina.core.StandardEngine startInternal
INFOS: Starting Servlet Engine: Apache Tomcat/8.0.33
juin 09, 2016 10:55:50 AM org.apache.catalina.core.ApplicationContext log
INFOS: Initializing Spring embedded WebApplicationContext
10:55:50.255 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root
WebApplicationContext: initialization completed in 1596 ms
...
10:55:55.765 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain
- Creating filter chain: ...]
10:55:55.785 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
10:55:55.791 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
...
10:55:56.249 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String)
throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.252 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.255 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws
com.fasterxml.jackson.core.JsonProcessingException
10:55:56.257 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.259 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET],produces=[application/json;charset=UTF-8]}" onto
public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.261 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}"
onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.264 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.266 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.268 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.270 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.273 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.276 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
10:55:56.681 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
10:55:56.686 [main] INFO rdvmedecins.boot.Boot - Started Boot in 8.231 seconds
Сервер виводить багато логів. Вище ми навели лише ті, що допомагають зрозуміти суть:
- рядки 14–18: на порту 8080 машини запускається вбудований сервер Tomcat. Саме цей сервер виконує веб-додаток для управління зустрічами. Цей додаток, по суті, є веб-сервісом / jSON: запити до нього надходять через URL, а він відповідає, надсилаючи рядок jSON;
- рядок 24: веб-сервіс захищений за допомогою фреймворку [Spring Security]. Доступ до URL веб-сервісу здійснюється після аутентифікації;
- рядки 29–44: URL, що надаються веб-сервісом;
Ми детально розглянемо їх.
3.5.2. Захист веб-сервісу
URL, що надаються веб-сервісом, захищені. Сервер очікує у запиті HTTP від клієнта наступний заголовок:
Очікуваний код — це кодування в base64 [http://fr.wikipedia.org/wiki/Base64] рядка «користувач:пароль». У початковому стані веб-сервіс приймає лише користувача «admin» з паролем «admin». Для цього конкретного користувача вищезазначений заголовок перетворюється на такий рядок:
Щоб надіслати цей заголовок HTTP, ми використовуємо клієнт HTTP [Advanced Rest Client], який є плагіном для браузера Chrome (див. параграф 6.13). Ми вручну протестуємо різні URL, що надаються веб-сервісом, щоб зрозуміти:
- які параметри очікує URL;
- яку саме відповідь він надає;
3.5.3. Список лікарів
URL [/getAllMedecins] дозволяє отримати список лікарів:
![]() |
- у [1] — запит, що надсилається до URL;
- у [2] — метод HTTP, використаний для цього запиту;
- в [3] — заголовок безпеки користувача (admin, admin) HTTP;
- у [4] надсилається запит HTTP;
Відповідь сервера така:
![]() |
- у [5] — відформатована відповідь сервера jSON;
![]() |
- у [6] — та сама відповідь у необробленому вигляді;
Формат [5] дозволяє краще побачити структуру відповіді. Усі відповіді веб-сервісу є екземплярами наступного класу [Response]:
package rdvmedecins.android.dao.service;
import java.util.List;
public class Response<T> {
// ----------------- властивості
// статус операції
private int status;
// можливі повідомлення про помилки
private List<String> messages;
// тіло відповіді
private T body;
// конструктори
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// методи getter та setter
...
}
- рядок 9: статус відповіді. Значення 0 означає, що помилки не було, в іншому випадку — що помилка сталася;
- рядок 11: список повідомлень про помилки, якщо помилка сталася;
- рядок 13: відповідь, яку насправді очікував клієнт;
Відповіддю на URL [/getAllMedecins] є рядок jSON об’єкта типу [Response<List<Medecin>>]. Клас [Medecin] має такий вигляд:
package rdvmedecins.android.dao.entities;
public class Medecin extends Personne {
// конструктор за замовчуванням
public Medecin() {
}
// конструктор з параметрами
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
У рядку 3 клас [Medecin] успадковує наступний клас [Personne]:
package rdvmedecins.android.dao.entities;
public class Personne extends AbstractEntity {
// атрибути особи
private String titre;
private String nom;
private String prenom;
// конструктор за замовчуванням
public Personne() {
}
// конструктор з параметрами
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// методи getter та setter
...
}
У рядку 3 клас [Personne] розширює наступний клас [AbstractEntity]:
package rdvmedecins.android.dao.entities;
import java.io.Serializable;
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
protected Long id;
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// ініціалізація
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1)) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id == other.id;
}
// методи getter та setter
...
}
У підсумку структура об’єкта [Medecin] є такою:
[Long id; Long version; String titre; String nom; String prenom;]
а структура об’єкта [Response<List<Medecin>>] — така:
Надалі ми будемо використовувати ці скорочені визначення для опису відповіді сервера. Крім того, протягом деякого часу ми більше не будемо показувати знімки екрана. Достатньо просто повторити те, що ми щойно розглянули. Ми повернемося до знімків екрана, коли знадобиться виконати запит POST. Ми також наведемо приклад виконання у такому вигляді:
3.5.4. Список клієнтів
| |
|
Приклад:
3.5.5. Список прийомних годин лікаря
|
- [idMedecin]: ідентифікатор лікаря, про якого потрібно дізнатися час прийому;
- [hdebut]: час початку прийому;
- [mdebut]: хвилини початку прийому;
- [hfin]: час закінчення прийому;
- [mfin]: хвилини закінчення прийому;
Для часового проміжку між 10:20 та 10:40 отримаємо [hdebut, mdebut, hfin, mfin]=[10, 20, 10, 40].
Приклад:
3.5.6. Список прийомів лікаря
|
- [idMedecin]: ідентифікатор лікаря, про якого потрібно отримати інформацію щодо прийомів;
- URL [jour]: день прийому у форматі «rrrr-мм-дд»;
- Відповідь [jour]: те саме, але у форматі дати Java;
- [client]: клієнт, для якого призначено зустріч. Його структура була описана раніше;
- [idClient]: ідентифікатор клієнта;
- [creneau]: часовий проміжок зустрічі. Його структура була описана раніше;
- [idCreneau]: ідентифікатор часового проміжку;
Приклад:
3.5.7. Розклад роботи лікаря
|
- [idMedecin]: ідентифікатор лікаря, про якого потрібно отримати інформацію про прийом;
- URL [jour]: день прийому у форматі «rrrr-мм-дд»;
- [agenda]: розклад лікаря;
- [medecin]: відповідний лікар. Його структура була визначена раніше;
- Відповідь [jour]: день у розкладі у форматі дати Java;
- [creneauxMedecinJour]: масив елементів типу [CreneauMedecinJour];
- [creneau]: часовий проміжок. Його структура була описана раніше;
- [rv]: зустріч. Її структура була описана раніше;
Приклад:
|
Ми виділили випадки, коли в часовому проміжку є зустріч, та випадки, коли її немає.
3.5.8. Пошук лікаря за ідентифікатором
|
- [idMedecin]: ідентифікатор лікаря;
Приклад 1:
Приклад 2:
3.5.9. Отримати клієнта за його ідентифікатором
|
- [idClient]: ідентифікатор клієнта;
Приклад 1:
Приклад 2:
3.5.10. Отримати слот за його ідентифікатором
|
- [idCreneau]: ідентифікатор слота;
Приклад 1:
Слід зауважити, що у відповіді не вказано лікаря, якому належить цей час, а лише його ідентифікатор.
Приклад 2:
3.5.11. Записатися на прийом за допомогою ідентифікатора
|
- [idRv]: ідентифікатор зустрічі;
Приклад 1:
Зверніть увагу, що у відповіді немає ані імені клієнта, ані часу зустрічі, а лише їхні ідентифікатори.
Приклад 2:
3.5.12. Додати зустріч
URL [/ajouterRv] дозволяє додати зустріч. Необхідна для цього інформація (день, часовий проміжок та клієнт) передається через запит HTTP POST. Ми покажемо, як виконати цей запит за допомогою інструменту [Advanced Rest Client].

- у [1] запитується URL;
- у [2] запит надсилається з POST;
- у [3-4] серверу вказується, що значення, які йому надсилаються, мають вигляд рядка jSON;
- у [4] — заголовок HTTP для автентифікації;
- у [5] — інформація, передана через POST. Це рядок jSON, що містить:
- [jour]: день зустрічі у форматі «aaaa-mm-jj»,
- [idClient]: ідентифікатор клієнта, для якого призначено зустріч,
- [idCreneau]: ідентифікатор часового проміжку зустрічі. Оскільки часовий проміжок належить конкретному лікарю, цим також позначається лікар;
- у [6] надсилається запит;
Рядок jSON, що надсилається, є об’єктом типу [PostAjouterRv], а саме:
public class PostAjouterRv {
// дані публікації
private String jour;
private long idClient;
private long idCreneau;
// конструктори
public PostAjouterRv() {
}
public PostAjouterRv(String jour, long idCreneau, long idClient) {
this.jour = jour;
this.idClient = idClient;
this.idCreneau = idCreneau;
}
// методи getter та setter
...
}
Відповідь сервера має тип [Response<Rv>] [int status; List<String> messages; Rv rv], де [rv] — це додана зустріч.
Відповідь сервера на вищезазначений запит має такий вигляд:
![]() |
Слід зауважити, що деяка інформація не вказана у [idClient, idCreneau], але її можна знайти у полях [client] та [creneau]. Важливою інформацією є ідентифікатор доданої зустрічі (209). Веб-сервіс міг би обмежитися поверненням лише цієї інформації.
3.5.13. Видалення зустрічі
Ця операція також виконується за допомогою POST:
|
Значенням, що передається, є рядок jSON з об’єкта типу [PostSupprimerRv], а саме:
public class PostSupprimerRv {
// дані запису
private long idRv;
// конструктори
public PostSupprimerRv() {
}
public PostSupprimerRv(long idRv) {
this.idRv = idRv;
}
// методи getter та setter
...
}
- у рядку 4 [idRv] — це ідентифікатор зустрічі, яку потрібно видалити.
Приклад 1:
Запис № 209 було скасовано через [status=0].
Приклад 2:
3.6. Клієнт для Android

Тепер, коли сервер [1] детально описано та він працює, ми розглянемо клієнт для Android [2].
3.6.1. Архітектура проєкту Android Studio
Проєкт повторює архітектуру проєкту [client-android-skel] (див. параграф 1.17). У наведеній вище архітектурі клієнта для Android можна виділити три блоки:
- рівень [DAO], відповідальний за взаємодію з веб-сервісом;
- [vues], що відповідають за взаємодію з користувачем;
- [activité], що є сполучною ланкою між двома попередніми блоками. Види не мають доступу до шару [DAO]. Вони взаємодіють лише з активністю.
Ця архітектура відображена в архітектурі проекту Android Studio для Android-клієнта:
![]() |
- пакет [activity] реалізує активність;
- пакет [architecture] містить елементи архітектури, які ми розробили раніше;
- пакет [dao] реалізує рівень [DAO];
- пакет [fragments] реалізує [vues];
3.6.2. Налаштування проекту
![]() |
Папка [architecture / custom] містить елементи архітектури, які можна налаштовувати.
Інтерфейс [IMainActivity] має такий вигляд:
package client.android.architecture.custom;
import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;
public interface IMainActivity extends IDao {
// доступ до сесії
ISession getSession();
// зміна подання
void navigateToView(int position, ISession.Action action);
// управління очікуванням
void beginWaiting();
void cancelWaiting();
// константи додатка -------------------------------------
// режим налагодження
boolean IS_DEBUG_ENABLED = true;
// максимальний час очікування відповіді сервера
int TIMEOUT = 1000;
// час очікування перед виконанням клієнтського запиту
int DELAY = 000;
// базова автентифікація
boolean IS_BASIC_AUTHENTIFICATION_NEEDED = true;
// суміжність фрагментів
int OFF_SCREEN_PAGE_LIMIT = 1;
// панель вкладок
boolean ARE_TABS_NEEDED = false;
// зображення очікування
boolean IS_WAITING_ICON_NEEDED = true;
// кількість фрагментів додатка
int FRAGMENTS_COUNT = 4;
// кількість переглядів
int VUE_CONFIG = 0;
int VUE_ACCUEIL = 1;
int VUE_AGENDA = 2;
int VUE_AJOUT_RV = 3;
}
- рядки 25, 28: налаштування рівня [DAO];
- рядок 31: ця програма здійснює аутентифікований доступ до сервера;
- рядок 40: потрібне зображення очікування;
- рядок 43: додаток має чотири фрагменти;
- рядки 46–49: номери чотирьох фрагментів;
- рядок 37: вкладок немає;
Базовий клас [CoreState] для станів фрагментів матиме такий вигляд:
package client.android.architecture.custom;
import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.AccueilFragmentState;
import client.android.fragments.state.AgendaFragmentState;
import client.android.fragments.state.AjoutRvFragmentState;
import client.android.fragments.state.ConfigFragmentState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
@JsonSubTypes.Type(value = AccueilFragmentState.class),
@JsonSubTypes.Type(value = AgendaFragmentState.class),
@JsonSubTypes.Type(value = AjoutRvFragmentState.class),
@JsonSubTypes.Type(value = ConfigFragmentState.class)
}
)
public class CoreState {
// чи відвідано фрагмент
protected boolean hasBeenVisited = false;
// стан можливого меню фрагмента
protected MenuItemState[] menuOptionsState;
// гетери та сеттери
...
}
- рядки 15–18: чотири фрагменти мають такий стан:
![]() |
Нарешті, сесія містить дані, спільні для фрагментів:
package client.android.architecture.custom;
import client.android.architecture.core.AbstractSession;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.Client;
import client.android.dao.entities.Medecin;
import client.android.fragments.state.AccueilFragmentState;
import client.android.fragments.state.AgendaFragmentState;
import client.android.fragments.state.AjoutRvFragmentState;
import client.android.fragments.state.ConfigFragmentState;
import java.util.List;
public class Session extends AbstractSession {
// елементи, які не можуть бути серіалізовані в jSON, повинні мати анотацію @JsonIgnore
// список лікарів
private List<Medecin> médecins;
// список клієнтів
private List<Client> clients;
// розклад лікаря на певний день
private AgendaMedecinJour agenda;
// позиція елемента, на який натиснули, у розкладі
private int position;
// дата прийому в англійському форматі «yyyy-MM-dd»
private String dayRv;
// день зустрічі у французькому форматі «dd-MM-yyyy»
private String jourRv;
// методи getter та setter
...
}
- рядки 17–28: сесія зберігає шість елементів інформації. Ми пояснимо їхню роль, коли це буде необхідно.
3.6.3. Шар [DAO]
![]() |
![]() | ![]() |
- у [1] — об’єкти, інкапсульовані у відповідях сервера. Вони були описані в розділі 3.5;
- у [2] — елементи клієнта, що керують обміном даними із сервером;
Ми не будемо повертатися до елементів [1]. Вони вже були розглянуті. У разі потреби читачеві пропонується повернутися до розділу 3.5. Ми розглянемо реалізацію пакета [service]. Це дасть нам змогу також обговорити реалізацію захищеного обміну даними між клієнтом і сервером.
3.6.3.1. Реалізація обміну даними між клієнтом і сервером
![]() |
Клас [WebClient] є компонентом AA, який описує:
- URL, що надаються веб-сервісом;
- їхні параметри;
- їхні відповіді;
package rdvmedecins.android.dao.service;
import rdvmedecins.android.dao.entities.*;
import org.androidannotations.rest.spring.annotations.*;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {
// RestTemplate
public void setRestTemplate(RestTemplate restTemplate);
// список лікарів
@Get("/getAllMedecins")
public Response<List<Medecin>> getAllMedecins();
// список клієнтів
@Get("/getAllClients")
public Response<List<Client>> getAllClients();
// список вільних часів лікаря
@Get("/getAllCreneaux/{idMedecin}")
public Response<List<Creneau>> getAllCreneaux(@Path long idMedecin);
// список прийомів лікаря
@Get("/getRvMedecinJour/{idMedecin}/{jour}")
public Response<List<Rv>> getRvMedecinJour(@Path long idMedecin, @Path String jour);
// Клієнт
@Get("/getClientById/{id}")
public Response<Client> getClientById(@Path long id);
// Лікар
@Get("/getMedecinById/{id}")
public Response<Medecin> getMedecinById(@Path long id);
// Прийом
@Get("/getRvById/{id}")
public Response<Rv> getRvById(@Path long id);
// Час
@Get("/getCreneauById/{id}")
public Response<Creneau> getCreneauById(@Path long id);
// додати RV
@Post("/ajouterRv")
public Response<Rv> ajouterRv(@Body PostAjouterRv post);
// видалити зустріч
@Post("/supprimerRv")
public Response<Rv> supprimerRv(@Body PostSupprimerRv post);
// отримати розклад прийому лікаря
@Get(value = "/getAgendaMedecinJour/{idMedecin}/{jour}")
public Response<AgendaMedecinJour> getAgendaMedecinJour(@Path long idMedecin, @Path String jour);
}
- рядки 19–60: тут містяться всі URL, розглянуті в розділі 3.5;
- рядок 16: компонент [RestTemplate] з [Spring Android], на якому базується взаємодія клієнт/сервер;
3.6.3.2. Інтерфейс [IDao]
![]() |
Інтерфейс [IDao] рівня [DAO] має такий вигляд:
package rdvmedecins.android.dao.service;
import rdvmedecins.android.dao.entities.*;
import rx.Observable;
import java.util.List;
public interface IDao {
// URL веб-сервісу
public void setUrlServiceWebJson(String url);
// користувач
public void setUser(String user, String mdp);
// час очікування клієнта
public void setTimeout(int timeout);
// список клієнтів
public Observable<List<Client>> getAllClients();
// список лікарів
public Observable<List<Medecin>> getAllMedecins();
// список часових інтервалів лікаря
public Observable<List<Creneau>> getAllCreneaux(long idMedecin);
// список прийомів лікаря на певний день
public Observable<List<Rv>> getRvMedecinJour(long idMedecin, String jour);
// знайти клієнта за його ідентифікаційним номером
public Observable<Client> getClientById(long id);
// пошук лікаря за його ідентифікатором
public Observable<Medecin> getMedecinById(long id);
// пошук прийому за його ідентифікатором
public Observable<Rv> getRvById(long id);
// знайти часовий проміжок за його ідентифікатором
public Observable<Creneau> getCreneauById(long id);
// додати RV
public Observable<Rv> ajouterRv(String jour, long idCreneau, long idClient);
// видалити RV
public Observable<Rv> supprimerRv(long idRv);
// професія
public Observable<AgendaMedecinJour> getAgendaMedecinJour(long idMedecin, String jour);
// режим налагодження
void setDebugMode(boolean isDebugEnabled);
}
- рядок 10: для визначення URL веб-сервісу / jSON;
- рядок 13: для визначення користувача клієнт-серверного з’єднання. [user] — ідентифікатор користувача, [mdp] — його пароль;
- рядок 16: для встановлення максимального часу очікування відповіді від сервера;
- рядки 18–49: кожному URL, що надається веб-сервісом, відповідає метод. Вони повторюють сигнатуру методів з такими самими іменами компонента AA [WebClient];
- рядок 52: для керування режимом debug на рівні [DAO];
3.6.3.3. Клас [Dao]
![]() |
Реалізація [DAO] попереднього інтерфейсу [IDao] виглядає наступним чином:
package client.android.dao.service;
import android.util.Log;
import client.android.dao.entities.*;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;
import java.util.ArrayList;
import java.util.List;
@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {
// клієнт веб-сервісу
@RestService
protected WebClient webClient;
// безпека
@Bean
protected MyAuthInterceptor authInterceptor;
// RestTemplate
private RestTemplate restTemplate;
// фабрика RestTemplate
private SimpleClientHttpRequestFactory factory;
@AfterInject
public void afterInject() {
...
}
@Override
public void setUrlServiceWebJson(String url) {
...
}
@Override
public void setUser(String user, String mdp) {
...
}
@Override
public void setTimeout(int timeout) {
...
}
@Override
public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
if (isDebugEnabled) {
Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
}
// перехоплювач автентифікації?
if (isBasicAuthentificationNeeded) {
// додаємо перехоплювач автентифікації
List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
interceptors.add(authInterceptor);
restTemplate.setInterceptors(interceptors);
}
}
// приватні методи -------------------------------------------------
private void log(String message) {
if (isDebugEnabled) {
Log.d(className, message);
}
}
// реалізація інтерфейсу IDao --------------------------------------------------------------------
@Override
public Observable<Response<List<Client>>> getAllClients() {
// журнал
log("getAllClients");
// результат
return getResponse(new IRequest<Response<List<Client>>>() {
@Override
public Response<List<Client>> getResponse() {
return webClient.getAllClients();
}
});
}
@Override
public Observable<Response<List<Medecin>>> getAllMedecins() {
// журнал
log("getAllMedecins");
// результат
return getResponse(new IRequest<Response<List<Medecin>>>() {
@Override
public Response<List<Medecin>> getResponse() {
return webClient.getAllMedecins();
}
});
}
@Override
public Observable<Response<List<Creneau>>> getAllCreneaux(final long idMedecin) {
// журнал
log("getAllCreneaux");
// результат
return getResponse(new IRequest<Response<List<Creneau>>>() {
@Override
public Response<List<Creneau>> getResponse() {
return webClient.getAllCreneaux(idMedecin);
}
});
}
@Override
public Observable<Response<List<Rv>>> getRvMedecinJour(final long idMedecin, final String jour) {
// журнал
log("getRvMedecinJour");
// результат
return getResponse(new IRequest<Response<List<Rv>>>() {
@Override
public Response<List<Rv>> getResponse() {
return webClient.getRvMedecinJour(idMedecin, jour);
}
});
}
@Override
public Observable<Response<Client>> getClientById(final long id) {
// журнал
log("getClientById");
// результат
return getResponse(new IRequest<Response<Client>>() {
@Override
public Response<Client> getResponse() {
return webClient.getClientById(id);
}
});
}
@Override
public Observable<Response<Medecin>> getMedecinById(final long id) {
// журнал
log("getMedecinById");
// результат
return getResponse(new IRequest<Response<Medecin>>() {
@Override
public Response<Medecin> getResponse() {
return webClient.getMedecinById(id);
}
});
}
@Override
public Observable<Response<Rv>> getRvById(final long id) {
// журнал
log("getRvById");
// результат
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.getRvById(id);
}
});
}
@Override
public Observable<Response<Creneau>> getCreneauById(final long id) {
// журнал
log("getCreneauById");
// результат
return getResponse(new IRequest<Response<Creneau>>() {
@Override
public Response<Creneau> getResponse() {
return webClient.getCreneauById(id);
}
});
}
@Override
public Observable<Response<Rv>> ajouterRv(final String jour, final long idCreneau, final long idClient) {
// журнал
log("ajouterRv");
// результат
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.ajouterRv(new PostAjouterRv(jour, idCreneau, idClient));
}
});
}
@Override
public Observable<Response<Rv>> supprimerRv(final long idRv) {
// журнал
log("supprimerRv");
// результат
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.supprimerRv(new PostSupprimerRv(idRv));
}
});
}
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(final long idMedecin, final String jour) {
// журнал
log("getAgendaMedecinJour");
// результат
return getResponse(new IRequest<Response<AgendaMedecinJour>>() {
@Override
public Response<AgendaMedecinJour> getResponse() {
return webClient.getAgendaMedecinJour(idMedecin, jour);
}
});
}
}
- рядки 18–72: це базові рядки, що містяться у класі [Dao] проекту [client-android-skel];
- рядки 74–216: реалізація інтерфейсу [IDao]. Методи, що запитують URL, надані веб-сервісом, делегують цей запит компоненту AA [WebClient] (рядки 22–23);
- рядки 58–63: якщо обмін даними між клієнтом і сервером автентифікується за допомогою базової авторизації, до компонента [RestTemplate] додається інтерцептор. Це призведе до того, що будь-який запит HTTP, відправлений компонентом [RestTemplate], буде перехоплений класом [MyAuthInterceptor] (рядки 25–26);
Клас [MyAuthInterceptor] має такий вигляд:
package rdvmedecins.android.dao.security;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.springframework.http.HttpAuthentication;
import org.springframework.http.HttpBasicAuthentication;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
@EBean(scope = EBean.Scope.Singleton)
public class MyAuthInterceptor implements ClientHttpRequestInterceptor {
// користувач
private String user;
private String mdp;
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
HttpHeaders headers = request.getHeaders();
HttpAuthentication auth = new HttpBasicAuthentication(user, mdp);
headers.setAuthorization(auth);
return execution.execute(request, body);
}
public void setUser(String user, String mdp) {
this.user = user;
this.mdp = mdp;
}
}
- рядок 15: клас [MyAuthInterceptor] є компонентом AA типу [singleton];
- рядок 16: клас [MyAuthInterceptor] розширює інтерфейс Spring [ClientHttpRequestInterceptor]. Цей інтерфейс має один метод — метод [intercept] у рядку 22. Цей інтерфейс розширюється для перехоплення будь-якого запиту HTTP від клієнта. Метод [intercept] приймає три параметри;
- [HtpRequest request]: перехоплений запит HTTP,
- [byte[] body]: його тіло, якщо воно є (наприклад, відправлені значення),
- [ClientHttpRequestExecution execution]: компонент Spring, який виконує запит;
Ми перехоплюємо всі запити HTTP від клієнта Android, щоб додати до них заголовок автентифікації HTTP, описаний у розділі 3.5.
- рядок 23: ми отримуємо заголовки HTTP із перехопленого запиту;
- рядок 24: ми створюємо заголовок автентифікації HTTP. Використовуваний режим автентифікації (кодування base64 рядка 'user:mdp') надається класом Spring [HttpBasicAuthentication];
- рядок 25: заголовок автентифікації, який ми щойно створили, додається до поточних заголовків перехопленого запиту;
- рядок 26: продовжується виконання перехопленого запиту. Якщо підсумувати, то перехоплений запит було доповнено заголовком автентифікації;
Реалізації методів інтерфейсу [IDao] виконуються за однаковим зразком. Розглянемо приклад методу [getAgendaMedecinJour]:
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(final long idMedecin, final String jour) {
// журнал
log("getAgendaMedecinJour");
// результат
return getResponse(new IRequest<Response<AgendaMedecinJour>>() {
@Override
public Response<AgendaMedecinJour> getResponse() {
return webClient.getAgendaMedecinJour(idMedecin, jour);
}
});
}
- рядок 2: метод очікує два параметри:
- [idMedecin]: ідентифікатор лікаря, розклад якого потрібно отримати;
- [jour] — день, за який потрібно отримати розклад;
- рядок 6: викликається метод [getResponse] батьківського класу [AbstractDao]. Цей метод очікує параметр типу [IRequest<T>], де T — тип, що повертається методом [getAgendaMedecinJour] у рядку 2, у даному випадку [Response<AgendaMedecinJour>]. Інтерфейс [IRequest] має лише один метод: [getResponse] (рядок 8);
- рядки 8–10: реалізація методу [IRequest.getResponse]. Цей метод повинен повертати результат, очікуваний методом [getAgendaMedecinJour] у рядку 2, типу [Response<AgendaMedecinJour>];
- рядок 9: відповідь повертається методом [webClient.getAgendaMedecinJour]:
// отримати розклад прийому лікаря
@Get(value = "/getAgendaMedecinJour/{idMedecin}/{jour}")
Response<AgendaMedecinJour> getAgendaMedecinJour(@Path long idMedecin, @Path String jour);
Параметри, що використовуються у рядку 9, є тими, що передаються методу [getAgendaMedecinJour] у рядку 2. З цієї причини ці параметри повинні мати атрибут final;
3.6.4. Активність [MainActivity]
Serveur ![]() |
![]() |
Клас [MainActivity] має такий вигляд:
package client.android.activity;
import android.util.Log;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.*;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.dao.service.Response;
import client.android.fragments.behavior.AccueilFragment_;
import client.android.fragments.behavior.AgendaFragment_;
import client.android.fragments.behavior.AjoutRvFragment_;
import client.android.fragments.behavior.ConfigFragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import rx.Observable;
import java.util.List;
@EActivity
public class MainActivity extends AbstractActivity {
// рівень [DAO]
@Bean(Dao.class)
protected IDao dao;
// базовий клас ---------------------------------------
@Override
protected void onCreateActivity() {
// журнал
if (IS_DEBUG_ENABLED) {
Log.d(className, "onCreateActivity");
}
}
@Override
protected IDao getDao() {
return dao;
}
@Override
protected AbstractFragment[] getFragments() {
AbstractFragment[] fragments= new AbstractFragment[]{new ConfigFragment_(), new AccueilFragment_(), new AgendaFragment_(), new AjoutRvFragment_()};
return fragments;
}
@Override
protected CharSequence getFragmentTitle(int position) {
return null;
}
@Override
protected void navigateOnTabSelected(int position) {
}
@Override
protected int getFirstView() {
return IMainActivity.VUE_CONFIG;
}
// інтерфейс IDao -----------------------------------------------------
...
@Override
public Observable<Response<List<Client>>> getAllClients() {
return dao.getAllClients();
}
@Override
public Observable<Response<List<Medecin>>> getAllMedecins() {
return dao.getAllMedecins();
}
@Override
public Observable<Response<List<Creneau>>> getAllCreneaux(long idMedecin) {
return dao.getAllCreneaux(idMedecin);
}
@Override
public Observable<Response<List<Rv>>> getRvMedecinJour(long idMedecin, String jour) {
return dao.getRvMedecinJour(idMedecin, jour);
}
@Override
public Observable<Response<Client>> getClientById(long id) {
return dao.getClientById(id);
}
@Override
public Observable<Response<Medecin>> getMedecinById(long id) {
return dao.getMedecinById(id);
}
@Override
public Observable<Response<Rv>> getRvById(long id) {
return dao.getRvById(id);
}
@Override
public Observable<Response<Creneau>> getCreneauById(long id) {
return dao.getCreneauById(id);
}
@Override
public Observable<Response<Rv>> ajouterRv(String jour, long idCreneau, long idClient) {
return dao.ajouterRv(jour, idCreneau, idClient);
}
@Override
public Observable<Response<Rv>> supprimerRv(long idRv) {
return dao.supprimerRv(idRv);
}
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(long idMedecin, String jour) {
return dao.getAgendaMedecinJour(idMedecin, jour);
}
}
- рядки 21–66: ці рядки містяться у базовій версії шаблону [client-android-skel];
- рядки 66–119: реалізація інтерфейсу [IDao]. Усі методи делегують роботу на рівень [DAO], що починається з рядка 26;
- рядки 42–46: метод [getFragments] повертає масив із чотирма фрагментами додатка;
- рядки 58–61: вікно конфігурації є першим вікном, яке відображається під час запуску додатка;
3.6.5. Сесія
![]() |
Клас [Session] слугує для зберігання інформації, яка має передаватися між фрагментами. Він має такий вигляд:
package rdvmedecins.android.architecture;
import rdvmedecins.android.dao.entities.AgendaMedecinJour;
import rdvmedecins.android.dao.entities.Client;
import rdvmedecins.android.dao.entities.Medecin;
import org.androidannotations.annotations.EBean;
import java.util.List;
@EBean(scope = EBean.Scope.Singleton)
public class Session {
// список лікарів
private List<Medecin> médecins;
// список клієнтів
private List<Client> clients;
// календар
private AgendaMedecinJour agenda;
// позиція елемента, на який натиснули, у календарі
private int position;
// дата зустрічі в англійському форматі «yyyy-MM-dd»
private String dayRv;
// день зустрічі у французькому форматі «dd-MM-yyyy»
private String jourRv;
// методи getter та setter
...
}
- рядок 10: клас [Session] є компонентом AA, екземпляр якого створено в єдиному примірнику;
- рядки 12–15: у цьому прикладі ми припустимо, що списки лікарів та клієнтів не змінюються. Їх буде запитуватися під час запуску додатка, і ми збережемо їх у сесії, щоб фрагменти могли ними користуватися;
- рядки 20–23: бажаний день для зустрічі. Він обробляється у двох формах: у французькому форматі (рядок 23) у клієнті Android та в англійському форматі (рядок 21) для обміну даними з сервером;
- рядок 19: позиція елемента, на який натиснули (посилання «додати» / «видалити») у календарі;
3.6.6. Управління вікном налаштувань
3.6.6.1. Вікно
Вікно налаштувань — це вікно, яке відображається під час запуску програми:

Елементи візуального інтерфейсу такі:
3.6.6.2. Фрагмент
Екран налаштувань керується таким фрагментом [ConfigFragment]:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.Client;
import client.android.dao.entities.Medecin;
import client.android.dao.service.Response;
import client.android.fragments.state.ConfigFragmentState;
import org.androidannotations.annotations.*;
import rx.functions.Action1;
import java.net.URI;
import java.util.List;
@EFragment(R.layout.config)
@OptionsMenu(R.menu.menu_config)
public class ConfigFragment extends AbstractFragment {
// елементи візуального інтерфейсу
@ViewById(R.id.edt_urlServiceRest)
protected EditText edtUrlServiceRest;
@ViewById(R.id.txt_errorUrlServiceRest)
protected TextView txtErrorUrlServiceRest;
@ViewById(R.id.txt_errorUtilisateur)
protected TextView txtErrorUtilisateur;
@ViewById(R.id.edt_utilisateur)
protected EditText edtUtilisateur;
@ViewById(R.id.edt_mdp)
protected EditText edtMdp;
// введення даних
private String urlServiceRest;
private String utilisateur;
private String mdp;
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
..
// реалізація методів батьківського класу -------------------------------------------
...
}
- рядок 25: фрагмент пов’язаний із таким меню [menu_config]:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
</menu>
- рядки 28–38: елементи візуального інтерфейсу;
- рядки 41–43: три поля введення даних у формі;
Клік на пункт меню [Valider] обробляється методом [doValider]:
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
// приховування попередніх повідомлень про помилки
txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
txtErrorUtilisateur.setVisibility(View.INVISIBLE);
// перевіряємо правильність введених даних
if (!isPageValid()) {
return;
}
// заповнюється URL веб-сервісу
mainActivity.setUrlServiceWebJson(urlServiceRest);
// заповнюємо дані користувача
mainActivity.setUser(utilisateur, mdp);
// початок очікування — запускаються 2 асинхронні завдання
beginWaiting(2);
// лікарі
executeInBackground(mainActivity.getAllMedecins(), new Action1<Response<List<Medecin>>>() {
@Override
public void call(Response<List<Medecin>> responseMedecins) {
// обробка відповіді
consumeMedecins(responseMedecins);
}
});
// клієнти
executeInBackground(mainActivity.getAllClients(), new Action1<Response<List<Client>>>() {
@Override
public void call(Response<List<Client>> responseClients) {
// обробка відповіді
consumeClients(responseClients);
}
});
}
private void consumeMedecins(Response<List<Medecin>> responseMedecins) {
// журнал
if (isDebugEnabled) {
Log.d(className, "consume médecins");
}
// помилка?
if (responseMedecins.getStatus() != 0) {
// повідомлення
showAlert(responseMedecins.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// лікарі зберігаються в сесії
session.setMédecins(responseMedecins.getBody());
}
private void consumeClients(Response<List<Client>> responseClients) {
// журнал
if (isDebugEnabled) {
Log.d(className, "consume clients");
}
// помилка?
if (responseClients.getStatus() != 0) {
// повідомлення
showAlert(responseClients.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// збереження даних про клієнтів у сесії
session.setClients(responseClients.getBody());
}
- рядки 8–10: перевіряється правильність трьох значень, введених у форму. Якщо форма недійсна, обробка не продовжується;
- рядки 11–14: дані, необхідні для шару [DAO], передаються до активності;
- рядок 16: батьківському класу повідомляється, що будуть запущені два асинхронні завдання, і готується режим очікування;
- рядки 17–24: запитується список лікарів;
- рядок 18: метод [executeInBackground] очікує два параметри:
- рядок 18: процес, який потрібно виконати та спостерігати, надається методом [mainActivity.getAllMedecins()];
- рядки 18–24: другим параметром є екземпляр типу [Action1<T>], де T — це тип, що повертається спостережуваним процесом, у даному випадку [Response<List<Medecin>>]
- рядок 22: коли отримуємо відповідь, передаємо її методу [consumeMedecins] у рядку 36;
- рядки 25–33: після запуску першого асинхронного завдання запускається друге для запиту списку клієнтів. Отже, ми матимемо два завдання, що виконуються паралельно;
- рядки 36–52: отримано відповідь від завдання щодо лікарів. Її обробляємо;
- рядки 42–49: спочатку перевіряємо, чи сервер повідомив про помилку в полі [status] відповіді;
- рядок 44: якщо помилка є, виводимо повідомлення, які сервер розмістив у полі [messages] відповіді;
- рядок 46: скасовуються всі завдання;
- рядок 48: повертаємося до інтерфейсу користувача;
- рядок 51: якщо помилки не було, список лікарів зберігається у сесії;
Правильність введених даних (рядок 8) перевіряється за допомогою такого методу:
private boolean isPageValid() {
// перевіряється правильність введених даних
boolean erreur;
URI service;
// дійсність URL служби REST
urlServiceRest = String.format("http://%s", edtUrlServiceRest.getText().toString().trim());
try {
service = new URI(urlServiceRest);
erreur = service.getHost() == null || service.getPort() == -1;
} catch (Exception ex) {
// фіксується помилка
erreur = true;
}
if (erreur) {
// відображення помилки
txtErrorUrlServiceRest.setVisibility(View.VISIBLE);
}
// користувач
utilisateur = edtUtilisateur.getText().toString().trim();
if (utilisateur.length() == 0) {
// відображається помилка
txtErrorUtilisateur.setVisibility(View.VISIBLE);
// помічено помилку
erreur = true;
}
// пароль
mdp = edtMdp.getText().toString().trim();
// повернення
return !erreur;
}
Метод [beginWaiting] (рядок 16) виглядає так:
// початок очікування
protected void beginWaiting(int numberOfRunningTasks) {
// готується запуск завдань
beginRunningTasks(numberOfRunningTasks);
// стан кнопок і меню
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- рядок 4: батьківському завданню вказується, що буде запущено завдання [numberOfRunningTasks];
- рядок 6: приховуються всі опції меню;
- рядок 7: щоб потім зробити видимим пункт меню [Actions/Annuler];
Клік на опцію меню [Annuler] обробляється методом [doAnnuler]:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// скасування асинхронних завдань
cancelRunningTasks();
}
- рядок 8: батьківський клас отримує запит на скасування асинхронних завдань;
3.6.6.3. Управління життєвим циклом фрагмента
Фрагмент має такий стан [ConfigFragmentState]:
package client.android.fragments.state;
import client.android.architecture.custom.CoreState;
public class ConfigFragmentState extends CoreState {
// видимість двох повідомлень про помилки
private boolean txtErrorUrlServiceRestVisible;
private boolean txtErrorUtilisateurVisible;
// гетери та сеттери
...
}
- коли батьківський клас звернеться до нього з відповідним запитом, фрагмент збереже інформацію про видимість своїх двох повідомлень про помилки;
Життєвий цикл фрагмента реалізовано наступним чином:
// реалізація методів батьківського класу -------------------------------------------
@Override
public CoreState saveFragment() {
// збереження стану фрагмента
ConfigFragmentState state = new ConfigFragmentState();
state.setTxtErrorUrlServiceRestVisible(txtErrorUrlServiceRest.getVisibility() == View.VISIBLE);
state.setTxtErrorUtilisateurVisible(txtErrorUtilisateur.getVisibility() == View.VISIBLE);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_CONFIG;
}
@Override
protected void initFragment(CoreState previousState) {
}
@Override
protected void initView(CoreState previousState) {
if (previousState == null) {
// перше відвідування
// кешування повідомлень про помилки
txtErrorUtilisateur.setVisibility(View.INVISIBLE);
txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
// меню
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
}
@Override
protected void updateOnRestore(CoreState previousState) {
// відновлення відображення повідомлень про помилки
ConfigFragmentState state = (ConfigFragmentState) previousState;
// не перший візит — відновити відображення повідомлень про помилки
txtErrorUtilisateur.setVisibility(state.isTxtErrorUtilisateurVisible() ? View.VISIBLE : View.INVISIBLE);
txtErrorUrlServiceRest.setVisibility(state.isTxtErrorUrlServiceRestVisible() ? View.VISIBLE : View.INVISIBLE);
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// меню
initMenu();
// наступний екран?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.SUBMIT);
}
}
// приватні методи ------------------------------------------------
private void initMenu(){
// стан меню
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- рядки 2–9: коли батьківський клас надсилає відповідний запит, фрагмент зберігає стан своїх двох повідомлень про помилки;
- рядки 11–14: номер фрагмента — [IMainActivity.VUE_CONFIG];
- рядки 16–19: виконуються, коли фрагмент генерується вперше (previousState == null) або регенерується наступного разу (previousState != null). Тут нічого робити не потрібно;
- рядки 21–31: виконуються, коли вигляд, пов’язаний із фрагментом, створюється вперше (previousState == null) або відтворюється наступного разу (previousState != null);
- рядки 24–29: під час першого відвідування приховуються повідомлення про помилки та відображається меню без дії [Annuler] (рядки 62–66);
- рядки 33–35: виконуються, коли до фрагмента потрапляють за допомогою операції [SUBMIT]. Тут це ніколи не відбувається;
- рядки 37–44: виконуються, коли до фрагмента потрапляють за допомогою операції [NAVIGATION] або [RESTORE]. Стан повідомлень про помилки відновлюється на основі попереднього стану;
- рядки 47–49: виконуються, коли всі попередні оновлення виконано. Більше нічого робити не потрібно;
- рядки 51–59: виконуються, коли всі асинхронні завдання завершені;
- рядки 53–54: меню повертається до стану за замовчуванням;
- рядки 56–58: якщо завдання завершилися нормально, то переходимо до наступного вікна, інакше залишаємося на тому самому вікні;
3.6.7. Управління стартовим екраном
3.6.7.1. Вікно
Початковий екран виглядає так:

Елементи візуального інтерфейсу такі:
3.6.7.2. Фрагмент
Головна сторінка керується таким фрагментом [AccueilFragment]:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.DatePicker;
import android.widget.Spinner;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.Medecin;
import client.android.dao.service.Response;
import client.android.fragments.state.AccueilFragmentState;
import org.androidannotations.annotations.*;
import rx.functions.Action1;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
@EFragment(R.layout.accueil)
@OptionsMenu(R.menu.menu_accueil)
public class AccueilFragment extends AbstractFragment {
// елементи візуального інтерфейсу
@ViewById(R.id.spinnerMedecins)
protected Spinner spinnerMedecins;
@ViewById(R.id.edt_JourRv)
protected DatePicker edtJourRv;
// локальні дані
private List<Medecin> medecins;
private Calendar calendrier;
private String[] spinnerMedecinsDataSource;
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
...
// реалізація методів батьківського класу -------------------------------------
...
}
- рядок 26: фрагмент пов’язаний із таким меню [menu_accueil]:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
</menu>
</item>
</menu>
- рядки 31–34: елементи візуального інтерфейсу;
- рядок 37: список лікарів;
- рядок 38: календар;
- рядок 39: джерело даних для спиннера лікарів;
Клік на посилання [Valider] обробляється наступним методом [doValider]:
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
// записуємо ідентифікатор обраного лікаря
Long idMedecin = medecins.get(spinnerMedecins.getSelectedItemPosition()).getId();
// зберігаємо день у сесії
String jourRv = String.format(new Locale("Fr-fr"), "%02d-%02d-%04d", edtJourRv.getDayOfMonth(), edtJourRv.getMonth() + 1, edtJourRv.getYear());
session.setJourRv(jourRv);
// перехід до формату дати yyyy-MM-dd
String dayRv = String.format(new Locale("Fr-fr"), "%04d-%02d-%02d", edtJourRv.getYear(), edtJourRv.getMonth() + 1, edtJourRv.getDayOfMonth());
session.setDayRv(dayRv);
// початок очікування — запускається 1 асинхронне завдання
beginWaiting(1);
// запитується розклад лікаря
executeInBackground(mainActivity.getAgendaMedecinJour(idMedecin, dayRv), new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// обробляється відповідь
consumeAgenda(responseAgendaMedecinJour);
}
});
}
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// помилка?
if (responseAgendaMedecinJour.getStatus() != 0) {
// повідомлення
showAlert(responseAgendaMedecinJour.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// додаємо календар до сесії
session.setAgenda(responseAgendaMedecinJour.getBody());
}
- рядок 5: отримуємо ідентифікатор обраного лікаря;
- рядки 7–8: вводиться у сесію, у французькому форматі, обрана дата;
- рядки 10–11: вноситься обрана дата у форматі англійської мови;
- рядок 13: повідомляємо батьківський клас про те, що буде запущено асинхронне завдання, та готуємося до очікування;
- рядки 15–22: запитується розклад лікаря;
- рядок 15: метод [executeInBackground] очікує на два параметри:
- рядок 15: процес, який потрібно виконати та спостерігати, надається методом [mainActivity.getAgendaMedecinJour(idMedecin, dayRv)];
- рядки 15–22: другим параметром є екземпляр типу [Action1<T>], де T — тип, що повертається спостережуваним процесом, у даному випадку [Response<AgendaMedecinJour>]
- рядок 20: коли отримуємо відповідь, передаємо її методу [consumeAgenda] у рядку 25;
- рядок 15: метод [executeInBackground] очікує на два параметри:
- рядки 25–37: отримано розклад лікаря. Його обробляють;
- рядки 27–34: спочатку перевіряємо, чи сервер повідомив про помилку в полі [status] відповіді;
- рядок 29: якщо помилка є, виводимо повідомлення, які сервер розмістив у полі [messages] відповіді;
- рядок 31: скасовуються всі завдання;
- рядок 33: повертаємося до інтерфейсу користувача;
- рядок 36: якщо помилок не було, календар завантажується;
Метод [beginWaiting] (рядок 13) виглядає наступним чином:
// початок очікування
protected void beginWaiting(int numberOfRunningTasks) {
// підготовка до запуску завдань
beginRunningTasks(numberOfRunningTasks);
// стан кнопок і меню
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- рядок 4: повідомляємо батьківському завданню, що будемо запускати завдання [numberOfRunningTasks];
- рядок 6: приховуються всі опції меню;
- рядок 7: щоб потім зробити видимим пункт меню [Actions/Annuler];
Клік на опцію меню [Annuler] обробляється методом [doAnnuler]:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// скасовуються асинхронні завдання
cancelRunningTasks();
}
- рядок 8: батьківський клас отримує запит на скасування асинхронних завдань;
Натискання на пункт меню [Retour à la configuration] обробляється наступним чином:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// перехід до вікна налаштувань
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
- рядок 4: перехід до вікна налаштувань за допомогою дії [NAVIGATION]. Це означає, що потрібно повернути вікно налаштувань у той стан, у якому воно було залишене;
3.6.7.3. Управління життєвим циклом фрагмента
Фрагмент має такий стан: [AccueilFragmentState]:
package client.android.fragments.state;
import android.widget.ArrayAdapter;
import client.android.architecture.custom.CoreState;
import client.android.dao.entities.CreneauMedecinJour;
public class AccueilFragmentState extends CoreState {
// стан фрагмента [Accueil]
// позиція обраного лікаря
private int selectedMedecinPosition;
// вибрана дата
private int year;
private int month;
private int dayOfMonth;
// джерело даних для списку лікарів
private String[] spinnerMedecinsDataSource;
// конструктори
public AccueilFragmentState() {
}
// методи getter та setter
...
}
- рядок 11: дозволяє відтворити елемент, вибраний у списку лікарів;
- рядки 13–15: дозволяють відтворити вибрану дату в календарі;
- рядок 17: дозволяє повернути джерело даних зі списку лікарів;
Життєвий цикл фрагмента реалізовано наступним чином:
// реалізація методів батьківського класу -------------------------------------
@Override
public CoreState saveFragment() {
// збереження подання
AccueilFragmentState state = new AccueilFragmentState();
state.setSelectedMedecinPosition(spinnerMedecins.getSelectedItemPosition());
state.setDayOfMonth(edtJourRv.getDayOfMonth());
state.setMonth(edtJourRv.getMonth());
state.setYear(edtJourRv.getYear());
state.setSpinnerMedecinsDataSource(spinnerMedecinsDataSource);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_ACCUEIL;
}
@Override
protected void initFragment(CoreState previousState) {
// отримання списку лікарів із сесії
medecins = session.getMédecins();
// Перший візит?
if (previousState == null) {
// побудова масиву, що відображається спінером
spinnerMedecinsDataSource = new String[medecins.size()];
int i = 0;
for (Medecin medecin : medecins) {
spinnerMedecinsDataSource[i] = String.format("%s %s %s", medecin.getTitre(), medecin.getPrenom(), medecin.getNom());
i++;
}
} else {
// не перший візит
AccueilFragmentState state = (AccueilFragmentState) previousState;
spinnerMedecinsDataSource = state.getSpinnerMedecinsDataSource();
}
// календар
calendrier = Calendar.getInstance();
}
@Override
protected void initView(CoreState previousState) {
// прив'язуємо спінер лікарів до його джерела даних
ArrayAdapter<String> dataAdapterMedecins = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, spinnerMedecinsDataSource);
dataAdapterMedecins.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerMedecins.setAdapter(dataAdapterMedecins);
// мінімальна дата в календарі — сьогодні
edtJourRv.setMinDate(calendrier.getTimeInMillis());
// перший візит?
if (previousState == null) {
// меню
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// меню
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// відновлюється стан поточної сесії
AccueilFragmentState state = (AccueilFragmentState) previousState;
// вибір лікарів у списку
spinnerMedecins.setSelection(state.getSelectedMedecinPosition());
// календар
edtJourRv.updateDate(state.getYear(), state.getMonth(), state.getDayOfMonth());
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// викликається після того, як усі завдання виконано або скасовано
// стан меню
initMenu();
// наступний вигляд?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
}
}
// приватні методи ------------------------------------------------
private void initMenu() {
// стан меню
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- рядки 2–9: коли батьківський клас надсилає відповідний запит, фрагмент зберігає стан таких елементів:
- рядок 6: позицію, вибрану у списку лікарів;
- рядки 7–9: день місяця, місяць і рік дати, вибраної в календарі;
- рядок 10: джерело даних для спиннера лікарів;
- рядки 14–17: номер фрагмента — [IMainActivity.VUE_ACCUEIL];
- рядки 19–39: виконуються, коли фрагмент генерується вперше (previousState == null) або регенерується наступного разу (previousState != null);
- рядки 25–31: у разі першого відвідування формується джерело даних для спінера лікарів;
- рядки 33–35: для наступних відвідувань джерело даних списку вибирається з попереднього стану фрагмента;
- рядки 41–54: виконуються, коли вікно, пов’язане з фрагментом, створюється вперше (previousState==null) або відновлюється під час наступних відвідувань (previousState !=null);
- рядки 50–53: під час першого відвідування відображається меню без дії [Annuler] (рядки 88–92);
- рядки 43–48: для всіх відвідувань, незалежно від того, чи це перше, чи ні, спиннер лікарів пов’язується з його джерелом (рядки 44–46), а мінімальна дата в календарі встановлюється на сьогоднішню дату (рядок 48);
- рядки 56–60: виконуються при переході до фрагмента за допомогою операції [SUBMIT]. У цьому випадку перехід здійснюється з подання [CONFIG]. Меню повертається до початкового стану;
- рядки 62–70: виконуються при переході до фрагмента за допомогою операції [NAVIGATION] або [RESTORE];
- рядок 67: перепозиціонуємо спінер лікарів на останнього вибраного лікаря;
- рядок 69: календар встановлюється на останню обрану дату;
- рядки 72–74: виконуються, коли всі попередні оновлення виконано. Більше нічого робити не потрібно;
- рядки 76–85: виконуються після завершення всіх асинхронних завдань;
- рядок 80: меню повертається до стану за замовчуванням;
- рядки 82–84: якщо завдання завершилися нормально, то переходимо до наступного вікна, інакше залишаємося в тому самому вікні;
3.6.8. Управління екраном «Календар»
3.6.8.1. Вигляд
Початковий вигляд виглядає так:

Елементи візуального інтерфейсу такі:
3.6.8.2. Фрагмент
Вигляд «Календар» керується таким фрагментом [AgendaFragment]:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.CreneauMedecinJour;
import client.android.dao.entities.Medecin;
import client.android.dao.entities.Rv;
import client.android.dao.service.Response;
import client.android.fragments.state.AgendaFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
import rx.functions.Action1;
@EFragment(R.layout.agenda)
@OptionsMenu(R.menu.menu_agenda)
public class AgendaFragment extends AbstractFragment {
// елементи візуального інтерфейсу
@ViewById(R.id.txt_titre2_agenda)
protected TextView txtTitre2;
@ViewById(R.id.listViewAgenda)
protected ListView lstCreneaux;
// календар, що відображається фрагментом
private AgendaMedecinJour agenda;
// інформація про часові проміжки
private int firstPosition;
private int top;
// запис видалено чи ні
private boolean rdvSupprimé;
// номер доданого або видаленого часового проміжку
private int numCréneau;
// Оновлення календаря після додавання/видалення
private void updateAgenda() {
...
}
...
// реалізація методів батьківського класу ------------------------------------------------------
...
}
- рядок 27: фрагмент пов’язаний із таким меню [menu_agenda]:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
<item
android:id="@+id/actionAgenda"
android:title="@string/actionAgenda"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
<item
android:id="@+id/navigationToAccueil"
android:title="@string/navigationToAccueil"/>
</menu>
</item>
</menu>
- рядки 32–35: елементи візуального інтерфейсу;
- рядки 37–45: глобальні дані методів;
3.6.8.2.1. Метод [updateAgenda]
(Пере)генерація списку часових проміжків у календарі необхідна в декількох місцях коду. Вона була винесена в наступний приватний метод [updateAgenda]:
// оновлення календаря після додавання/видалення
private void updateAgenda() {
// (пере)генерація часових проміжків у календарі
// календар зберігається в сесії та записується у поле фрагмента
agenda = session.getAgenda();
// повторне формування ListView інтервалів
ArrayAdapter<CreneauMedecinJour> adapter = new ListCreneauxAdapter(activity, R.layout.creneau_medecin,
agenda.getCreneauxMedecinJour(), this);
lstCreneaux.setAdapter(adapter);
// повернення до потрібного місця у ListView
lstCreneaux.setSelectionFromTop(firstPosition, top);
}
- рядок 5: календар отримується з сесії та зберігається у полі [agenda] фрагмента;
- рядки 7–9: визначається адаптер компонента [ListView]. Цей адаптер визначає як джерело даних [ListView], так і модель відображення кожного його елемента. Ми незабаром розглянемо цей адаптер;
- рядок 11: повертаємося до попереднього положення календаря. Адже ми бачимо лише частину часових проміжків дня. Якщо додати або видалити зустріч в останньому часовому проміжку, наведений вище код оновить сторінку, щоб відобразити новий календар. Це оновлення призводить до того, що ми знову опиняємося на першому проміжку часу, що є небажаним. Рядок 5 вирішує цю проблему. Опис цього рішення можна знайти в URL [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview];
Клас [ListCreneauxAdapter] слугує для визначення рядка у [ListView]:

Як бачимо вище, залежно від того, чи є у часовому проміжку зустріч, чи ні, відображення відрізняється. Код класу [ListCreneauxAdapter] такий:
...
public class ListCreneauxAdapter extends ArrayAdapter<CreneauMedecinJour> {
// таблиця часових інтервалів
private CreneauMedecinJour[] creneauxMedecinJour;
// контекст виконання
private Context context;
// ідентифікатор макета відображення рядка у списку часових проміжків
private int layoutResourceId;
// обробник кліків
private AgendaFragment vue;
// конструктор
public ListCreneauxAdapter(Context context, int layoutResourceId, CreneauMedecinJour[] creneauxMedecinJour,
AgendaFragment vue) {
super(context, layoutResourceId, creneauxMedecinJour);
// зберігаємо інформацію
this.creneauxMedecinJour = creneauxMedecinJour;
this.context = context;
this.layoutResourceId = layoutResourceId;
this.vue = vue;
// сортування масиву часових проміжків за порядком розкладу
Arrays.sort(creneauxMedecinJour, new MyComparator());
}
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
...
}
// сортування таблиці часових проміжків
class MyComparator implements Comparator<CreneauMedecinJour> {
...
}
}
- рядок 3: клас [ListCreneauxAdapter] повинен розширювати попередньо визначений адаптер для [ListView], а саме клас [ArrayAdapter], який, як випливає з назви, передає класу [ListView] масив об’єктів, у даному випадку типу [CreneauMedecinJour]. Нагадаємо код цієї сутності:
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// поля
private Creneau creneau;
private Rv rv;
...
}
- клас [CreneauMedecinJour] містить часовий проміжок (рядок 5) та можливу зустріч (рядок 6) або null, якщо зустрічі немає;
Повернемося до коду класу [ListCreneauxAdapter]:
- рядок 15: конструктор отримує чотири параметри:
- поточний процес Android,
- файл XML, що визначає вміст кожного елемента [ListView],
- масив часових проміжків лікаря,
- сам вигляд;
- рядок 24: масив часових проміжків відсортований у порядку зростання часу;
Метод [getView] відповідає за формування подання, що відповідає рядку файлу [ListView]. Воно складається з трьох елементів:
Код методу [getView] такий:
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
// вибираємо потрібний часовий проміжок
CreneauMedecinJour creneauMedecin = creneauxMedecinJour[position];
// створюємо рядок
View row = ((Activity) context).getLayoutInflater().inflate(layoutResourceId, parent, false);
// часовий проміжок
TextView txtCreneau = (TextView) row.findViewById(R.id.txt_Creneau);
txtCreneau.setText(String.format("%02d:%02d-%02d:%02d", creneauMedecin.getCreneau().getHdebut(), creneauMedecin
.getCreneau().getMdebut(), creneauMedecin.getCreneau().getHfin(), creneauMedecin.getCreneau().getMfin()));
// клієнт
TextView txtClient = (TextView) row.findViewById(R.id.txt_Client);
String text;
if (creneauMedecin.getRv() != null) {
Client client = creneauMedecin.getRv().getClient();
text = String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom());
} else {
text = "";
}
txtClient.setText(text);
// посилання
final TextView btnValider = (TextView) row.findViewById(R.id.btn_Valider);
if (creneauMedecin.getRv() == null) {
// додати
btnValider.setText(R.string.btn_ajouter);
btnValider.setTextColor(context.getResources().getColor(R.color.blue));
} else {
// видалити
btnValider.setText(R.string.btn_supprimer);
btnValider.setTextColor(context.getResources().getColor(R.color.red));
}
// обробник події посилання
btnValider.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// передаємо інформацію до подання календаря
vue.doValider(position, btnValider.getText().toString());
}
});
// відображаємо рядок
return row;
}
- рядок 2: «position» — це номер рядка, який буде згенеровано в [ListView]. Це також номер часового проміжку в таблиці [creneauxMedecinJour]. Два інші параметри ігноруються;
- рядок 4: отримуємо часовий проміжок, який потрібно відобразити у рядку файлу [ListView];
- рядок 6: рядок формується на основі його визначення XML
![]() |
Код [creneau_medecin.xml] має такий вигляд:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/RelativeLayout1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/wheat" >
<TextView
android:id="@+id/txt_Creneau"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginLeft="20dp"
android:text="@string/txt_dummy" />
<TextView
android:id="@+id/txt_Client"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_Creneau"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_Creneau"
android:text="@string/txt_dummy" />
<TextView
android:id="@+id/btn_Valider"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_Client"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_Client"
android:text="@string/btn_valider"
android:textColor="@color/blue" />
</RelativeLayout>
- рядки 8–10: формується часовий проміжок [1];
- рядки 12–20: формується ідентифікатор клієнта [2];
- рядок 23: якщо у часовому проміжку немає зустрічі;
- рядки 25–26: створюється посилання [Ajouter] синього кольору;
- рядки 29–30: інакше створюється посилання [Supprimer] червоного кольору;
- рядки 33–40: незалежно від типу посилання [Ajouter / Supprimer], саме метод [doValider] цього виду оброблятиме клік на посилання. Метод отримає два аргументи:
- номер сегмента, на який натиснули,
- текст посилання, на яке натиснули;
- рядок 42: повертаємо щойно створений рядок.
Зазначимо, що саме метод [doValider] фрагмента [AgendaFragment] обробляє посилання. Він має такий вигляд:
// натискання на посилання [Ajouter / Supprimer]
public void doValider(int numCréneau, String texte) {
// операція виконується?
if (numberOfRunningTasks != 0) {
Toast.makeText(activity, "Une opération est en cours. Patientez ou Annulez...", Toast.LENGTH_SHORT).show();
return;
}
// запам'ятовуємо положення прокрутки, щоб повернутися до нього
// прочитати [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
// позиція першого елемента: повністю видимий чи ні
firstPosition = lstCreneaux.getFirstVisiblePosition();
// зсув по осі Y цього елемента відносно верхньої межі ListView
// вимірює висоту частини, яка може бути прихована
View v = lstCreneaux.getChildAt(0);
top = (v == null) ? 0 : v.getTop();
// також фіксується номер кліканого сегмента
this.numCréneau = numCréneau;
// залежно від тексту посилання, дії відрізняються
if (texte.equals(getResources().getString(R.string.lnk_ajouter))) {
doAjouter();
} else {
doSupprimer();
}
}
- метод [doValider] отримує дві інформації:
- номер сегмента, на який було натиснуто;
- текст (Додати / Видалити) посилання, на яке натиснули;
- рядки 4–7: клік на посилання [Supprimer / Ajouter] блокується, якщо виконуються асинхронні завдання. Це рішення, яке полегшує написання коду. Воно може бути предметом обговорення;
- рядки 11–15: інформація (firstPosition, top) про ListView інтервалів записується у поля фрагмента, щоб приватний метод [updateAgenda] міг відтворити його з тим самим положенням прокрутки;
- рядок 17: записується номер слота, на який натиснули;
- рядки 19–23: залежно від тексту натиснутого посилання виконується додавання або видалення;
3.6.8.2.2. Метод [doSupprimer]
Метод [doSupprimer] забезпечує видалення зустрічі з натиснутого слоту:
// скасування зустрічі
private void doSupprimer() {
// очікування завершення двох завдань
beginWaiting(2);
// зустріч видаляється у фоновому режимі
rdvSupprimé = false;
// ідентифікатор зустрічі, яку потрібно видалити
long idRv = agenda.getCreneauxMedecinJour()[numCréneau].getRv().getId();
// видалення за допомогою асинхронного завдання
executeInBackground(mainActivity.supprimerRv(idRv), new Action1<Response<Rv>>() {
@Override
public void call(Response<Rv> responseRv) {
// обробка результату
consumeRv(responseRv);
}
});
}
// обробка відповіді
private void consumeRv(Response<Rv> responseRv) {
// помилка?
if (responseRv.getStatus() != 0) {
// повідомлення
showAlert(responseRv.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// відзначається, що зустріч було скасовано
rdvSupprimé = true;
// запитується найновіший календар
executeInBackground(
mainActivity.getAgendaMedecinJour(agenda.getMedecin().getId(), session.getDayRv()),
new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// обробка відповіді
consumeAgenda(responseAgendaMedecinJour);
}
});
}
// отримано календар
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// помилка?
if (responseAgendaMedecinJour.getStatus() != 0) {
// повідомлення
showAlert(responseAgendaMedecinJour.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// календар додається до сесії
session.setAgenda(responseAgendaMedecinJour.getBody());
// оновлення календаря у вікні
updateAgenda();
}
- рядок 4: повідомляємо батьківський клас про те, що будемо запускати два асинхронні завдання, і починаємо очікувати завершення цих двох завдань;
- рядок 8: отримується ідентифікатор зустрічі, яку потрібно видалити. Адже серверу потрібна ця інформація;
- рядки 9–18: надсилається запит на видалення зустрічі за допомогою асинхронного завдання;
- рядок 10: метод [executeInBackground] очікує два параметри:
- рядок 10: процес, який потрібно виконати та спостерігати, надається методом [mainActivity.supprimerRv(idRv)];
- рядки 10–17: другим параметром є екземпляр типу [Action1<T>], де T — тип, що повертається спостережуваним процесом, у даному випадку [Response<Rv>]
- рядок 15: після отримання відповіді її передають до методу [consumeRv] у рядку 21;
- рядок 10: метод [executeInBackground] очікує два параметри:
- рядки 21–44: отримано відповідь від асинхронного завдання. Її обробляють;
- рядки 23–30: спочатку перевіряємо, чи сервер повідомив про помилку в полі [status] відповіді;
- рядок 25: якщо помилка є, виводимо повідомлення, які сервер розмістив у полі [messages] відповіді;
- рядок 27: скасовуються всі завдання;
- рядок 29: повертаємося до інтерфейсу користувача;
- рядок 32: якщо помилки не було, фіксується, що зустріч було видалено;
- рядки 34–43: замість того, щоб просто видалити зустріч із календаря, який наразі відображається у фрагменті, запитується новий календар лікаря. Адже додаток є багатокористувацьким, і інші користувачі також могли внести зміни до календаря лікаря. Тому краще мати найсвіжішу версію;
- рядки 34–43, 47–61: повторюємо те, що було зроблено у фрагменті [AccueilFragment], але цього разу використовуючи інформацію, отриману з сесії;
Метод [beginWaiting] (рядок 4) виглядає так:
// початок очікування
protected void beginWaiting(int numberOfRunningTasks) {
// готуємо запуск завдань
beginRunningTasks(numberOfRunningTasks);
// стан кнопок і меню
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- рядок 4: повідомляємо батьківському завданню, що будемо запускати завдання [numberOfRunningTasks];
- рядок 6: приховуються всі опції меню;
- рядок 7: щоб потім зробити видимим пункт меню [Actions/Annuler];
3.6.8.2.3. Метод [doAnnuler]
Клік на опцію меню [Annuler] обробляється методом [doAnnuler]:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// скасовуються асинхронні завдання
cancelRunningTasks();
}
- рядок 7: надсилається запит до батьківського класу на скасування асинхронних завдань;
3.6.8.2.4. Пункт меню [Retour à la configuration]
Натискання на пункт меню [Retour à la configuration] обробляється наступним чином:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// перехід до вікна налаштувань
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
- рядок 4: перехід до вікна налаштувань за допомогою дії [NAVIGATION]. Це означає, що потрібно відновити вікно налаштувань у тому стані, в якому воно було залишене;
3.6.8.2.5. Пункт меню [Retour à l'accueil]
Клік на пункт меню [Retour à l'accueil] обробляється аналогічно:
@OptionsItem(R.id.navigationToAccueil)
protected void navigationToAccueil() {
// перехід до головного екрану
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.NAVIGATION);
}
3.6.8.3. Управління життєвим циклом фрагмента
Фрагмент має такий стан: [AgendaFragmentState]:
package client.android.fragments.state;
import android.widget.ArrayAdapter;
import client.android.architecture.custom.CoreState;
import client.android.dao.entities.CreneauMedecinJour;
public class AgendaFragmentState extends CoreState {
// заголовок екрану
private String titre;
// ListView
private int firstPosition;
private int top;
// конструктори
public AgendaFragmentState() {
}
public AgendaFragmentState(String titre) {
this.titre = titre;
}
// методи getter та setter
...
}
- рядок 10: заголовок, що відображається у верхній частині вікна;
- рядки 12–13: дозволяють відобразити scrolling з ListView щодо вільних часів лікаря;
Життєвий цикл фрагмента реалізовано наступним чином:
// реалізація методів батьківського класу ------------------------------------------------------
@Override
public CoreState saveFragment() {
// збереження стану
AgendaFragmentState state = new AgendaFragmentState();
state.setTitre(txtTitre2.getText().toString());
// записування позиції прокрутки для повернення до неї
// прочитати [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
// позиція першого елемента: повністю видимий чи ні
firstPosition = lstCreneaux.getFirstVisiblePosition();
// зсув по осі Y цього елемента відносно верхньої межі ListView
// вимірює висоту частини, яка, можливо, прихована
View v = lstCreneaux.getChildAt(0);
top = (v == null) ? 0 : v.getTop();
// все це запам'ятовується
state.setTop(top);
state.setFirstPosition(firstPosition);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_AGENDA;
}
@Override
protected void initFragment(CoreState previousState) {
// Перший візит?
if (previousState != null) {
// не перший візит
AgendaFragmentState state = (AgendaFragmentState) previousState;
// та інформацію з ListView
firstPosition = state.getFirstPosition();
top = state.getTop();
}
}
@Override
protected void initView(CoreState previousState) {
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// отримуємо календар
agenda = session.getAgenda();
// генеруємо заголовок сторінки
Medecin medecin = agenda.getMedecin();
txtTitre2.setText(String.format("Rendez-vous de %s %s %s le %s", medecin.getTitre(), medecin.getPrenom(),
medecin.getNom(), session.getJourRv()));
// стан меню
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// перегенерувати заголовок сторінки
AgendaFragmentState state = (AgendaFragmentState) previousState;
txtTitre2.setText(state.getTitre());
}
@Override
protected void notifyEndOfUpdates() {
// повторне формування списку часових проміжків
updateAgenda();
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// стан меню
initMenu();
// у разі скасування, але зустріч було видалено, необхідно оновити локальний календар
if (runningTasksHaveBeenCanceled && rdvSupprimé) {
// видаляємо зустріч із локального календаря (не вдалося отримати доступ до загального календаря)
agenda.getCreneauxMedecinJour()[numCréneau].setRv(null);
// оновлюємо візуальний інтерфейс
updateAgenda();
}
}
// приватні методи ------------------------------------------------
private void initMenu() {
// стан меню
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- рядки 2–19: коли його батьківський клас надсилає відповідний запит, фрагмент зберігає стан таких елементів:
- рядок 6: заголовок, що відображається у верхній частині вікна;
- рядки 7–17: інформація (top, firstPosition), яка дозволить відтворити scrolling з ListView;
- рядки 21–24: номер фрагмента — [IMainActivity.VUE_AGENDA];
- рядки 26–35: виконуються, коли фрагмент генерується вперше (previousState == null) або регенерується наступного разу (previousState != null);
- рядки 30–34: якщо це не перший виклик фрагмента, отримується інформація (top, firstPosition), яка дозволить відтворити scrolling з ListView;
- рядки 38–40: виконуються, коли вид, пов’язаний із фрагментом, створюється вперше (previousState == null) або відновлюється наступного разу (previousState != null). Тут нічого робити не потрібно, оскільки ListView для слотів буде згенеровано приватним методом [updateAgenda] (рядки 61–65);
- рядки 42–52: виконуються, коли до фрагмента потрапляють через операцію [SUBMIT]. У цьому випадку перехід відбувається з подання [ACCUEIL];
- рядок 45: отримується календар, ініційований методом [AccueilFragment];
- рядки 47–49: генерується заголовок подання;
- операція ListView для часових проміжків буде згенерована за допомогою приватного методу [updateAgenda] (рядки 61–65);
- рядки 54–59: виконуються при переході до фрагмента за допомогою операції [NAVIGATION] або [RESTORE];
- рядки 57–58: відбувається перегенерація заголовка подання;
- ListView для слотів буде згенеровано за допомогою приватного методу [updateAgenda] (рядки 61–65);
- рядки 72–74: виконуються після завершення всіх попередніх оновлень. Оновлюється ListView для слотів, оскільки це оновлення необхідне незалежно від того, яким чином здійснюється перехід до фрагмента;
- рядки 67–77: виконуються після завершення всіх асинхронних завдань;
- рядок 70: меню повертається до стану за замовчуванням (рядки 82–86);
- рядок 72: було два асинхронних завдання. Перевіряється, чи перше (видалення зустрічі) було виконано успішно, незважаючи на скасування;
- рядок 74: якщо так, то зустріч видаляється з локального календаря
- рядок 75: і оновлюємо його відображення;
3.6.9. Управління екраном додавання зустрічі
3.6.9.1. Екран
Екран додавання зустрічі виглядає так:

Елементи візуального інтерфейсу такі:
3.6.9.2. Фрагмент
Екран додавання зустрічі керується таким фрагментом [AjoutRvFragment]:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.*;
import client.android.dao.service.Response;
import client.android.fragments.state.AjoutRvFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
import rx.functions.Action1;
import java.util.List;
import java.util.Locale;
@EFragment(R.layout.ajout_rv)
@OptionsMenu(R.menu.menu_ajout_rv)
public class AjoutRvFragment extends AbstractFragment {
// елементи візуального інтерфейсу
@ViewById(R.id.spinnerClients)
protected Spinner spinnerClients;
@ViewById(R.id.txt_titre2_ajoutRv)
protected TextView txtTitre2;
// клієнти
private List<Client> clients;
// локальні дані
private Creneau creneau;
private Medecin medecin;
private boolean rdvAjouté;
private Rv rv;
private String[] spinnerClientsDataSource;
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
...
// реалізація методів батьківського класу ----------------------------------
...
}
- рядок 26: фрагмент пов’язаний із таким меню [menu_ajout_rv]:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
<item
android:id="@+id/navigationToAccueil"
android:title="@string/navigationToAccueil"/>
<item
android:id="@+id/navigationToAgenda"
android:title="@string/navigationToAgenda"/>
</menu>
</item>
</menu>
- рядки 30–33: елементи візуального інтерфейсу;
- рядок 36: список клієнтів;
- рядок 43: джерело даних для спінера клієнтів;
Клік на посилання [Valider] обробляється наступним методом [doValider]:
// клієнти
private List<Client> clients;
// локальні дані
private Creneau creneau;
private Medecin medecin;
private boolean rdvAjouté;
private Rv rv;
private String[] spinnerClientsDataSource;
...
// перевірка сторінки
@OptionsItem(R.id.actionValider)
protected void doValider() {
// отримання обраного клієнта
Client client = clients.get(spinnerClients.getSelectedItemPosition());
// початок очікування двох асинхронних завдань
beginWaiting(2);
// додаємо RV
rdvAjouté = false;
executeInBackground(
mainActivity.ajouterRv(session.getDayRv(), creneau.getId(), client.getId()),
new Action1<Response<Rv>>() {
@Override
public void call(Response<Rv> responseRv) {
// обробка відповіді
consumeRv(responseRv);
}
});
}
// обробка об’єкта Response<Rv>
void consumeRv(Response<Rv> responseRv) {
// помилка?
if (responseRv.getStatus() != 0) {
// повідомлення
showAlert(responseRv.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// відзначається, що зустріч додано
rdvAjouté = true;
// запам'ятовується зустріч
this.rv = responseRv.getBody();
// запитується новий календар
executeInBackground(mainActivity.getAgendaMedecinJour(session.getAgenda().getMedecin().getId(), session.getDayRv()), new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// обробляється відповідь
consumeAgenda(responseAgendaMedecinJour);
}
});
}
// обробка об’єкта Response<AgendaMedecinJour>
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// помилка?
if (responseAgendaMedecinJour.getStatus() != 0) {
// повідомлення
showAlert(responseAgendaMedecinJour.getMessages());
// скасування
doAnnuler();
// повернення до UI
return;
}
// додаємо календар до сесії
session.setAgenda(responseAgendaMedecinJour.getBody());
}
- рядок 13: коли запускається метод [doValider], поля 2, 5, 6 та 9 вже ініціалізовано під час життєвого циклу фрагмента. Ми розглянемо, як саме;
- рядок 15: отримуємо суть [Client], що відповідає елементу, вибраному в списку клієнтів;
- рядок 17: повідомляємо батьківський клас про те, що будемо запускати два асинхронні завдання, і готуємося до очікування;
- рядок 19: спочатку зустріч ще не додана до календаря лікаря;
- рядки 20–30: надсилається запит до сервера на додавання зустрічі;
- рядок 20: метод [executeInBackground] очікує два параметри:
- рядок 20: процес, який потрібно виконати та спостерігати, надається методом [mainActivity.ajouterRv(session.getDayRv(), creneau.getId(), client.getId())];
- рядки 22–29: другий параметр — це екземпляр типу [Action1<T>], де T — тип, що повертається спостережуваним процесом, у даному випадку [Response<Rv>]
- рядок 27: після отримання відповіді її передають методу [consumeRV] у рядку 33;
- рядок 20: метод [executeInBackground] очікує два параметри:
- рядки 33–56: отримано відповідь від сервера. Її обробляють;
- рядки 35–42: спочатку перевіряємо, чи сервер повідомив про помилку в полі [status] відповіді;
- рядок 37: якщо помилка є, виводимо повідомлення, які сервер розмістив у полі [messages] відповіді;
- рядок 39: скасовуються всі завдання;
- рядок 41 : повертаємося до інтерфейсу користувача;
- рядок 44: якщо помилки не було, фіксується, що зустріч додано;
- рядок 46: збережено доданий запис про зустріч у полі фрагмента;
- рядки 47–55: як і під час видалення зустрічі, після її додавання запитуємо у сервера найсвіжіший розклад лікаря;
- рядки 47–56, 59–71: тут міститься код, який ми вже бачили кілька разів;
Метод [beginWaiting] (рядок 17) виглядає так:
// початок очікування
protected void beginWaiting(int numberOfRunningTasks) {
// підготовка до запуску завдань
beginRunningTasks(numberOfRunningTasks);
// стан кнопок і меню
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- рядок 4: батьківському завданню вказується, що буде запущено завдання [numberOfRunningTasks];
- рядок 6: приховуються всі опції меню;
- рядок 7: щоб потім зробити видимим пункт меню [Actions/Annuler];
Клік на опцію меню [Annuler] обробляється методом [doAnnuler]:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// скасування асинхронних завдань
cancelRunningTasks();
}
- рядок 7: батьківський клас отримує запит на скасування асинхронних завдань;
Навігація назад забезпечується трьома наступними методами:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// перехід до вікна налаштувань
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
@OptionsItem(R.id.navigationToAccueil)
protected void navigationToAccueil() {
// перехід до вікна налаштувань
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.NAVIGATION);
}
@OptionsItem(R.id.navigationToAgenda)
protected void navigationToAgenda() {
// перехід до екрану календаря
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.NAVIGATION);
}
3.6.9.3. Управління життєвим циклом фрагмента
Фрагмент має такий стан: [AjoutRvFragmentState]:
package client.android.fragments.state;
import client.android.architecture.custom.CoreState;
// стан фрагмента AjoutRvFragment
public class AjoutRvFragmentState extends CoreState {
// позиція вибраного клієнта
private int selectedClientPosition;
// заголовок екрану
private String titre;
// джерело даних для списку клієнтів
private String[] spinnerClientsDataSource;
// методи getter та setter
...
}
Життєвий цикл фрагмента реалізовано таким чином:
// реалізація методів батьківського класу ----------------------------------
@Override
public CoreState saveFragment() {
// збереження подання
AjoutRvFragmentState state = new AjoutRvFragmentState();
state.setTitre(txtTitre2.getText().toString());
state.setSelectedClientPosition(spinnerClients.getSelectedItemPosition());
state.setSpinnerClientsDataSource(spinnerClientsDataSource);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_AJOUT_RV;
}
@Override
protected void initFragment(CoreState previousState) {
// отримання клієнтів у сесії
clients = session.getClients();
// Перший візит?
if (previousState == null) {
// побудова масиву, що відображається спінером
spinnerClientsDataSource = new String[clients.size()];
int i = 0;
for (Client client : clients) {
spinnerClientsDataSource[i] = String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom());
i++;
}
} else {
// не перший візит
AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
spinnerClientsDataSource = state.getSpinnerClientsDataSource();
}
}
@Override
protected void initView(CoreState previousState) {
// прив'язка спінера до його джерела даних
ArrayAdapter<String> dataAdapterClients = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item,
spinnerClientsDataSource);
dataAdapterClients.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerClients.setAdapter(dataAdapterClients);
// Перший візит?
if (previousState == null) {
// меню
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// отримуємо номер слота, який потрібно забронювати в сесії
int position = session.getPosition();
// отримано розклад лікаря в сесії
AgendaMedecinJour agenda = session.getAgenda();
// отримуємо дані про лікаря та час, на який буде призначено зустріч
medecin = agenda.getMedecin();
creneau = agenda.getCreneauxMedecinJour()[position].getCreneau();
// формується заголовок 2 сторінки
String jour = session.getJourRv();
txtTitre2.setText(String.format(Locale.FRANCE,
"Prise de rendez-vous de %s %s %s le %s pour le créneau %02d:%02d-%02d:%02d", medecin.getTitre(),
medecin.getPrenom(), medecin.getNom(), jour, creneau.getHdebut(), creneau.getMdebut(), creneau.getHfin(),
creneau.getMfin()));
// вибір клієнта
spinnerClients.setSelection(0);
// меню
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// відновлення попереднього стану
AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
// заголовок
txtTitre2.setText(state.getTitre());
// індикатор завантаження
spinnerClients.setSelection(state.getSelectedClientPosition());
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// стан меню
initMenu();
// наступний вигляд?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
return;
}
// відбулося скасування — зустріч уже додано?
if (rdvAjouté) {
// змінюємо локальний календар (не отримали загальний календар)
AgendaMedecinJour agenda = session.getAgenda();
agenda.getCreneauxMedecinJour()[session.getPosition()].setRv(rv);
// відображається календар
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
return;
}
}
// приватні методи -------------------
private void initMenu() {
// стан меню
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- рядки 2–10: коли його батьківський клас надсилає відповідний запит, фрагмент зберігає стан таких елементів:
- рядок 6: заголовок у верхній частині вікна;
- рядок 7: позиція вибраного елемента у списку клієнтів;
- рядок 8: джерело даних списку клієнтів;
- рядки 12–15: номер фрагмента — [IMainActivity.VUE_AJOUT_RV];
- рядки 17–35: виконуються під час першого створення фрагмента (previousState == null) або під час його повторного створення (previousState != null);
- рядок 20: отримується список клієнтів із сесії для розміщення його в полі фрагмента;
- рядки 22–30: у разі першого відвідування формується джерело даних для списку клієнтів;
- рядки 32–33: для наступних відвідувань джерело даних списку клієнтів отримується з попереднього стану фрагмента;
- рядки 37–49: виконуються, коли подання, пов’язане з фрагментом, створюється вперше (previousState == null) або відновлюється в наступні рази (previousState != null);
- рядки 40–43: у будь-якому випадку спінер клієнтів пов’язується зі своїм джерелом даних;
- рядки 45–48: під час першого відвідування меню відображається без дії [Annuler] (рядки 107–111);
- рядки 51–70: виконуються при переході до фрагмента за допомогою операції [SUBMIT]. При цьому перехід здійснюється з перегляду [AGENDA];
- рядок 54: отримується номер часового проміжку, у який буде внесено зустріч;
- рядки 56–59: отримуємо об’єкти [Medecin] та [Creneau], необхідні для додавання цієї зустрічі, і розміщуємо їх у полях фрагмента;
- рядки 61–65: за допомогою цієї інформації можна сформувати заголовок вікна;
- рядок 67: спиннер клієнтів встановлюється на перший елемент;
- рядок 69: меню переводиться у початковий стан (без опції [Annuler]);
- рядки 72–80: виконуються при переході до фрагмента за допомогою операції [NAVIGATION] або [RESTORE];
- рядок 77: перегенерується заголовок подання;
- рядок 79: спиннер клієнтів перепозиціонується на останнього вибраного клієнта;
- рядки 82–84: виконуються після завершення всіх попередніх оновлень. Тут більше нічого робити не потрібно;
- рядки 86–104: виконуються, коли всі асинхронні завдання завершені;
- рядок 89: меню повертається до стану за замовчуванням;
- рядки 91–94: якщо завдання завершилися нормально, то повертаємося до екрану [AGENDA] за допомогою [SUBMIT] (тут це також могла бути дія типу NAVIGATION);
- рядки 96–103: якщо завдання завершилися скасуванням, все одно перевіряється, чи було додано зустріч (це означало б, що саме отримання нового календаря завершилося невдачею);
- рядки 98–99: якщо зустріч було додано;
- рядки 98–99: зустріч, надана сервером, додається до поточного календаря, тобто того, що відкритий у сеансі;
- рядок 101: повернення до перегляду [AGENDA] за допомогою [SUBMIT] (тут це також могла бути дія типу NAVIGATION);
3.7. Exécution
Виконайте такі тести:
- використовуйте додаток у звичайних умовах і переконайтеся, що він працює;
- поверніть пристрій для кожного з екранів і переконайтеся, що кожен з них відновлюється правильно;
- встановіть час очікування в кілька секунд у [IMainActivity];
- потім скасуйте завдання та переконайтеся, що отриманий результат відповідає очікуваному;
- поверніть пристрій під час очікування та переконайтеся, що завдання успішно скасовані й не відбувається збій;
- змінити сусідство фрагментів у [IMainActivity] та перевірити, чи додаток продовжує працювати;












































