diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8b1b56d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: android +android: + components: + - build-tools-27.0.3 + - tools + - platform-tools + - android-27 + licenses: + - 'android-sdk-license-.+' + - 'google-gdk-license-.+' + - 'google-gdk-license-.+' + +before_install: + - cd Android + - chmod +x gradlew + +script: + - ./gradlew assemble diff --git a/Android/app/build.gradle b/Android/app/build.gradle index 50ce3af..c7ef515 100644 --- a/Android/app/build.gradle +++ b/Android/app/build.gradle @@ -2,19 +2,24 @@ apply plugin: 'com.android.application' android { compileSdkVersion 27 + defaultConfig { applicationId "com.khsm.app" minSdkVersion project.minSdkVersion targetSdkVersion project.targetSdkVersion versionCode project.versionCode versionName project.versionName + + vectorDrawables.useSupportLibrary = true } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + compileOptions { targetCompatibility 1.8 sourceCompatibility 1.8 @@ -26,6 +31,8 @@ dependencies { implementation "com.android.support:appcompat-v7:$project.ANDROID_SUPPORT_VERSION" implementation "com.android.support.constraint:constraint-layout:$project.CONSTRAINT_LAYOUT_VERSION" implementation "com.android.support:recyclerview-v7:$project.ANDROID_SUPPORT_VERSION" + implementation "com.android.support:design:$project.ANDROID_SUPPORT_VERSION" + implementation "com.android.support:cardview-v7:$project.ANDROID_SUPPORT_VERSION" // io.reactivex.rxjava2 implementation "io.reactivex.rxjava2:rxandroid:$project.RX_ANDROID_VERSION" @@ -39,4 +46,11 @@ dependencies { // com.squareup.okhttp3 implementation "com.squareup.okhttp3:okhttp:$project.OKHTTP3_VERSION" implementation "com.squareup.okhttp3:logging-interceptor:$project.OKHTTP3_VERSION" + + // glide + implementation "com.github.bumptech.glide:glide:$project.GLIDE_VERSION" + annotationProcessor "com.github.bumptech.glide:compiler:$project.GLIDE_VERSION" + + // com.f2prateek.rx.preferences2 + implementation "com.f2prateek.rx.preferences2:rx-preferences:$project.RX_PREFERENCES_VERSION" } diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index c55b162..47bc6df 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> + @@ -18,6 +19,13 @@ + + + + + \ No newline at end of file diff --git a/Android/app/src/main/java/com/khsm/app/data/api/Api.java b/Android/app/src/main/java/com/khsm/app/data/api/Api.java index 477f6d4..e58d1bd 100644 --- a/Android/app/src/main/java/com/khsm/app/data/api/Api.java +++ b/Android/app/src/main/java/com/khsm/app/data/api/Api.java @@ -1,7 +1,19 @@ package com.khsm.app.data.api; +import android.support.annotation.NonNull; + import com.khsm.app.data.api.base.ApiBase; -import com.khsm.app.data.api.entities.User; +import com.khsm.app.data.api.entities.RankingsFilterInfo; +import com.khsm.app.data.entities.CreateSessionRequest; +import com.khsm.app.data.entities.CreateUserRequest; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.DisciplineRecord; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.News; +import com.khsm.app.data.entities.Result; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; import java.util.List; @@ -10,12 +22,94 @@ public class Api extends ApiBase { private final RestApi restApi; - Api(RestApi restApi) { + Api(RestApi restApi, AuthInterceptor authInterceptor) { + super(authInterceptor); this.restApi = restApi; } + public Single> getMeetings() { + return restApi.getMeetings() + .compose(this::processResponse); + } + + public Single> getDisciplines() { + return restApi.getDisciplines() + .compose(this::processResponse); + } + public Single> getUsers() { return restApi.getUsers() .compose(this::processResponse); } + + public Single> getMeetingResults(int id) { + return restApi.getMeetingResults(id) + .compose(this::processResponse); + } + + public Single getLastMeeting() { + return restApi.getLastMeeting() + .compose(this::processResponse); + } + + public Single login(CreateSessionRequest createSessionRequest) { + return restApi.login(createSessionRequest) + .compose(this::processResponse); + } + + public Single register(CreateUserRequest createUserRequest) { + return restApi.register(createUserRequest) + .compose(this::processResponse); + } + + public Single createMeeting(Meeting meeting){ + return restApi.createMeeting(meeting) + .compose(this::processResponse); + } + + public Single createResult(Result result){ + return restApi.createResult(result) + .compose(this::processResponse); + } + + public Single updateUser(User user){ + return restApi.updateUser(user) + .compose(this::processResponse); + } + + public Single> getMyResults() { + return restApi.getMyResults() + .compose(this::processResponse); + } + + public Single> getMyRecords() { + return restApi.getMyRecords() + .compose(this::processResponse); + } + + public Single> getRankings(@NonNull RankingsFilterInfo rankingsFilterInfo) { + return restApi.getRankings(rankingsFilterInfo.filterType, rankingsFilterInfo.sortType, rankingsFilterInfo.gender) + .compose(this::processResponse); + } + + public Single> getNews() { + return restApi.getNews() + .compose(this::processResponse); + } + + public Single addNews(News news){ + return restApi.addNews(news) + .compose(this::processResponse); + } + + public Single> getNewsResults(int id) { + return restApi.getNewsResults(id) + .compose(this::processResponse); + } + + public Single getLastNews() { + return restApi.getLastNews() + .compose(this::processResponse); + } + } diff --git a/Android/app/src/main/java/com/khsm/app/data/api/ApiFactory.java b/Android/app/src/main/java/com/khsm/app/data/api/ApiFactory.java index 4b5adfb..ed507e0 100644 --- a/Android/app/src/main/java/com/khsm/app/data/api/ApiFactory.java +++ b/Android/app/src/main/java/com/khsm/app/data/api/ApiFactory.java @@ -1,22 +1,41 @@ package com.khsm.app.data.api; +import android.content.Context; +import android.support.annotation.NonNull; + import com.khsm.app.BuildConfig; +import com.khsm.app.data.api.base.ApiBase; import com.khsm.app.data.api.core.ApiBuilderHelper; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.preferences.SessionStore; import okhttp3.logging.HttpLoggingInterceptor; public abstract class ApiFactory { - public static Api createApi() { + public static Api createApi(@NonNull Context context) { ApiBuilderHelper apiBuilderHelper = new ApiBuilderHelper(); + ApiBase.AuthInterceptor authInterceptor = new ApiBase.AuthInterceptor(); + + apiBuilderHelper.addInterceptor(authInterceptor); + if (BuildConfig.DEBUG) { apiBuilderHelper.addLoggingInterceptor(HttpLoggingInterceptor.Level.BODY); } - String serviceUrl = "http://10.0.2.2:5000/"; + String serviceUrl = "http://10.0.2.2:5000/api/"; RestApi restApi = apiBuilderHelper.createRetrofitApi(serviceUrl, RestApi.class); - return new Api(restApi); + Api api = new Api(restApi, authInterceptor); + + SessionStore sessionStore = new SessionStore(context); + + Session session = sessionStore.getSession(); + if (session != null) { + api.setSessionToken(session.token); + } + + return api; } } diff --git a/Android/app/src/main/java/com/khsm/app/data/api/RestApi.java b/Android/app/src/main/java/com/khsm/app/data/api/RestApi.java index 72cbdd6..1cbfb77 100644 --- a/Android/app/src/main/java/com/khsm/app/data/api/RestApi.java +++ b/Android/app/src/main/java/com/khsm/app/data/api/RestApi.java @@ -1,13 +1,77 @@ package com.khsm.app.data.api; -import com.khsm.app.data.api.entities.User; +import com.khsm.app.data.api.entities.RankingsFilterInfo; +import com.khsm.app.data.entities.CreateSessionRequest; +import com.khsm.app.data.entities.CreateUserRequest; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.DisciplineRecord; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.data.entities.Gender; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.News; +import com.khsm.app.data.entities.Result; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; import java.util.List; import io.reactivex.Single; +import retrofit2.http.Body; import retrofit2.http.GET; +import retrofit2.http.POST; +import retrofit2.http.PUT; +import retrofit2.http.Path; +import retrofit2.http.Query; interface RestApi { - @GET("api/users") + @GET("meetings") + Single> getMeetings(); + + @GET("meetings/last") + Single getLastMeeting(); + + @GET("disciplines") + Single> getDisciplines(); + + @GET("meetings/{id}/results") + Single> getMeetingResults(@Path("id") int id); + + @POST("sessions") + Single login(@Body CreateSessionRequest createSessionRequest); + + @POST("users") + Single register(@Body CreateUserRequest createUserRequest); + + @POST("meetings") + Single createMeeting(@Body Meeting meeting); + + @POST("meetings/results") + Single createResult(@Body Result result); + + @PUT("users/me") + Single updateUser(@Body User user); + + @GET("users/me/results") + Single> getMyResults(); + + @GET("users/me/records") + Single> getMyRecords(); + + @GET("rankings") + Single> getRankings(@Query("type") RankingsFilterInfo.FilterType type, @Query("sort") RankingsFilterInfo.SortType sort, @Query("gender") Gender gender); + + @GET("news") + Single> getNews(); + + @GET("news/last") + Single getLastNews(); + + @GET("news/{id}/results") + Single> getNewsResults(@Path("id") int id); + + @POST("news") + Single addNews(@Body News news); + + @GET("users") Single> getUsers(); } \ No newline at end of file diff --git a/Android/app/src/main/java/com/khsm/app/data/api/base/ApiBase.java b/Android/app/src/main/java/com/khsm/app/data/api/base/ApiBase.java index 6d7ff8c..78b2269 100644 --- a/Android/app/src/main/java/com/khsm/app/data/api/base/ApiBase.java +++ b/Android/app/src/main/java/com/khsm/app/data/api/base/ApiBase.java @@ -1,10 +1,25 @@ package com.khsm.app.data.api.base; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.io.IOException; + import io.reactivex.Completable; import io.reactivex.Single; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; public class ApiBase { - protected ApiBase() { + private final AuthInterceptor authInterceptor; + + protected ApiBase(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + public void setSessionToken(@Nullable String sessionToken) { + authInterceptor.sessionToken = sessionToken; } protected Completable processResponse(Completable upstream) { @@ -14,4 +29,29 @@ protected Completable processResponse(Completable upstream) { protected Single processResponse(Single upstream) { return upstream; // TODO: 14.02.2018 add generic error handling } + + //region Interceptor + public static class AuthInterceptor implements Interceptor { + @Nullable + private String sessionToken; + + private static final String HEADER_AUTHORIZATION = "Authorization"; + + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + Request.Builder builder = chain.request().newBuilder(); + + addHeaders(builder); + + return chain.proceed(builder.build()); + } + + private void addHeaders(@NonNull Request.Builder builder) { + String authentication = sessionToken; + if (authentication != null) { + builder.addHeader(HEADER_AUTHORIZATION, authentication); + } + } + } + //endregion } diff --git a/Android/app/src/main/java/com/khsm/app/data/api/converters/EnumRetrofitConverterFactory.java b/Android/app/src/main/java/com/khsm/app/data/api/converters/EnumRetrofitConverterFactory.java new file mode 100644 index 0000000..1753710 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/api/converters/EnumRetrofitConverterFactory.java @@ -0,0 +1,35 @@ +package com.khsm.app.data.api.converters; + +import android.support.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import retrofit2.Converter; +import retrofit2.Retrofit; + +public class EnumRetrofitConverterFactory extends Converter.Factory { + @Override + public Converter stringConverter(Type type, Annotation[] annotations, Retrofit retrofit) { + Converter converter = null; + if (type instanceof Class && ((Class)type).isEnum()) { + converter = value -> EnumUtils.GetSerializedNameValue((Enum) value); + } + return converter; + } + + public static class EnumUtils { + @Nullable + static > String GetSerializedNameValue(E e) { + String value = null; + try { + value = e.getClass().getField(e.name()).getAnnotation(SerializedName.class).value(); + } catch (NoSuchFieldException exception) { + exception.printStackTrace(); + } + return value; + } + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/khsm/app/data/api/core/ApiBuilderHelper.java b/Android/app/src/main/java/com/khsm/app/data/api/core/ApiBuilderHelper.java index 88ce2a1..661efd9 100644 --- a/Android/app/src/main/java/com/khsm/app/data/api/core/ApiBuilderHelper.java +++ b/Android/app/src/main/java/com/khsm/app/data/api/core/ApiBuilderHelper.java @@ -1,6 +1,7 @@ package com.khsm.app.data.api.core; import com.google.gson.GsonBuilder; +import com.khsm.app.data.api.converters.EnumRetrofitConverterFactory; import java.util.ArrayList; import java.util.HashMap; @@ -43,6 +44,7 @@ public T createRetrofitApi(String url, Class service) { .baseUrl(url) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(provideGsonConverterFactory()) + .addConverterFactory(new EnumRetrofitConverterFactory()) .client(createOkHttpClient()) .build(); } @@ -69,6 +71,8 @@ private GsonConverterFactory provideGsonConverterFactory() { gsonBuilder.registerTypeAdapter(entry.getKey(), entry.getValue()); } + gsonBuilder.setDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + return GsonConverterFactory.create(gsonBuilder.create()); } diff --git a/Android/app/src/main/java/com/khsm/app/data/api/entities/RankingsFilterInfo.java b/Android/app/src/main/java/com/khsm/app/data/api/entities/RankingsFilterInfo.java new file mode 100644 index 0000000..3e6a503 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/api/entities/RankingsFilterInfo.java @@ -0,0 +1,32 @@ +package com.khsm.app.data.api.entities; + +import com.google.gson.annotations.SerializedName; +import com.khsm.app.data.entities.Gender; + +import java.io.Serializable; + +public class RankingsFilterInfo implements Serializable { + public final FilterType filterType; + public final SortType sortType; + public final Gender gender; + + public RankingsFilterInfo(FilterType filterType, SortType sortType, Gender gender) { + this.filterType = filterType; + this.sortType = sortType; + this.gender = gender; + } + + public enum FilterType { + @SerializedName("average") + Average, + @SerializedName("single") + Single + } + + public enum SortType { + @SerializedName("ascending") + Ascending, + @SerializedName("descending") + Descending + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/api/entities/User.java b/Android/app/src/main/java/com/khsm/app/data/api/entities/User.java deleted file mode 100644 index 2af5970..0000000 --- a/Android/app/src/main/java/com/khsm/app/data/api/entities/User.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.khsm.app.data.api.entities; - -public class User { - private final Integer id; - private final String firstName; - - public User(Integer id, String firstName) { - this.id = id; - this.firstName = firstName; - } - - public Integer getId() { - return id; - } - - public String getFirstName() { - return firstName; - } -} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/CreateSessionRequest.java b/Android/app/src/main/java/com/khsm/app/data/entities/CreateSessionRequest.java new file mode 100644 index 0000000..da6e1f4 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/CreateSessionRequest.java @@ -0,0 +1,12 @@ +package com.khsm.app.data.entities; + +public class CreateSessionRequest { + public final String email; + public final String password; + + public CreateSessionRequest(String email, String password) { + this.email = email; + this.password = password; + } + +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/CreateUserRequest.java b/Android/app/src/main/java/com/khsm/app/data/entities/CreateUserRequest.java new file mode 100644 index 0000000..d55e74d --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/CreateUserRequest.java @@ -0,0 +1,12 @@ +package com.khsm.app.data.entities; + +public class CreateUserRequest { + public final User user; + public final String password; + + + public CreateUserRequest(User user, String password) { + this.user = user; + this.password = password; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/Discipline.java b/Android/app/src/main/java/com/khsm/app/data/entities/Discipline.java new file mode 100644 index 0000000..71a6595 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/Discipline.java @@ -0,0 +1,15 @@ +package com.khsm.app.data.entities; + +import java.io.Serializable; + +public class Discipline implements Serializable{ + public final Integer id; + public final String name; + public final String description; + + public Discipline(Integer id, String name, String description){ + this.id = id; + this.name = name; + this.description = description; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineRecord.java b/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineRecord.java new file mode 100644 index 0000000..a7fe503 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineRecord.java @@ -0,0 +1,13 @@ +package com.khsm.app.data.entities; + +public class DisciplineRecord { + public final Discipline discipline; + public final Result bestSingleResult; + public final Result bestAverageResult; + + public DisciplineRecord(Discipline discipline, Result bestSingleResult, Result bestAverageResult) { + this.discipline = discipline; + this.bestSingleResult = bestSingleResult; + this.bestAverageResult = bestAverageResult; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineResults.java b/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineResults.java new file mode 100644 index 0000000..38f63cf --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/DisciplineResults.java @@ -0,0 +1,13 @@ +package com.khsm.app.data.entities; + +import java.util.List; + +public class DisciplineResults { + public final Discipline discipline; + public final List results; + + public DisciplineResults(Discipline discipline, List results) { + this.discipline = discipline; + this.results = results; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/Gender.java b/Android/app/src/main/java/com/khsm/app/data/entities/Gender.java new file mode 100644 index 0000000..dc0f072 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/Gender.java @@ -0,0 +1,10 @@ +package com.khsm.app.data.entities; + +import com.google.gson.annotations.SerializedName; + +public enum Gender { + @SerializedName("male") + MALE, + @SerializedName("female") + FEMALE +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/Meeting.java b/Android/app/src/main/java/com/khsm/app/data/entities/Meeting.java new file mode 100644 index 0000000..426086a --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/Meeting.java @@ -0,0 +1,20 @@ +package com.khsm.app.data.entities; + +import java.io.Serializable; +import java.util.Date; + +public class Meeting implements Serializable { + public final Integer id; + public final Integer number; + public final Date date; + + public Meeting(Integer id, Integer number, Date date){ + this.id = id; + this.number = number; + this.date = date; + } + + public Meeting(Integer number, Date date){ + this(null, number, date); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/News.java b/Android/app/src/main/java/com/khsm/app/data/entities/News.java new file mode 100644 index 0000000..0ea4816 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/News.java @@ -0,0 +1,17 @@ +package com.khsm.app.data.entities; + +import java.util.Date; + +public class News { + public final Integer id; + public final User user; + public final String text; + public final Date dateAndTime; + + public News(Integer id, User user, String text, Date dateAndTime) { + this.id = id; + this.user = user; + this.text = text; + this.dateAndTime = dateAndTime; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/Result.java b/Android/app/src/main/java/com/khsm/app/data/entities/Result.java new file mode 100644 index 0000000..3d71bbd --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/Result.java @@ -0,0 +1,27 @@ +package com.khsm.app.data.entities; + +import java.util.List; + +public class Result { + public final Integer id; + public final Meeting meeting; + public final User user; + public final Float average; + public final List attempts; + public final Integer attemptCount; + public final Discipline discipline; + + public Result(Integer id, Meeting meeting, User user, Float average, List attempts, Integer attemptCount, Discipline discipline) { + this.id = id; + this.meeting = meeting; + this.user = user; + this.average = average; + this.attempts = attempts; + this.attemptCount = attemptCount; + this.discipline = discipline; + } + + public Result(Meeting meeting, User user, List attempts, Discipline discipline) { + this(null, meeting, user, null, attempts, null, discipline); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/Session.java b/Android/app/src/main/java/com/khsm/app/data/entities/Session.java new file mode 100644 index 0000000..dca1785 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/Session.java @@ -0,0 +1,11 @@ +package com.khsm.app.data.entities; + +public class Session { + public final String token; + public final User user; + + public Session(String token, User user) { + this.token = token; + this.user = user; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/entities/User.java b/Android/app/src/main/java/com/khsm/app/data/entities/User.java new file mode 100644 index 0000000..945c298 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/entities/User.java @@ -0,0 +1,45 @@ +package com.khsm.app.data.entities; + +import com.google.gson.annotations.SerializedName; + +import java.util.Date; +import java.util.List; + +public class User { + public static final String ROLE_ADMIN = "Admin"; + + public final Integer id; + public final String firstName; + public final String lastName; + public final String city; + @SerializedName("wcaid") + public final String wcaId; + public final String phoneNumber; + public final Gender gender; + public final Date birthDate; + public final Date approved; + public final String email; + public final List roles; + + public User(Integer id, String firstName, String lastName, String city, String wcaId, String phoneNumber, Gender gender, Date birthDate, Date approved, String email, List roles) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.city = city; + this.wcaId = wcaId; + this.phoneNumber = phoneNumber; + this.gender = gender; + this.birthDate = birthDate; + this.approved = approved; + this.email = email; + this.roles = roles; + } + + public User(String firstName, String lastName, Gender gender, String email) { + this(null, firstName, lastName, null, null, null, gender, null, null, email, null); + } + + public User(String firstName, String lastName, Gender gender, String city, String wcaId, String phoneNumber, Date birthDate) { + this(null, firstName, lastName, city, wcaId, phoneNumber, gender, birthDate, null, null, null); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/preferences/ISettingsGlobalStorage.java b/Android/app/src/main/java/com/khsm/app/data/preferences/ISettingsGlobalStorage.java new file mode 100644 index 0000000..5e389cb --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/preferences/ISettingsGlobalStorage.java @@ -0,0 +1,17 @@ +package com.khsm.app.data.preferences; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +@SuppressWarnings("unused") +public interface ISettingsGlobalStorage { + @Nullable + T getObject(@NonNull String key, @NonNull Class tClass); + + @Nullable + T getObject(@NonNull String key, @NonNull Class tClass, @SuppressWarnings("SameParameterValue") @Nullable T defaultValue); + + void setObject(@NonNull String key, @Nullable T value); + + void clean(); +} diff --git a/Android/app/src/main/java/com/khsm/app/data/preferences/SessionStore.java b/Android/app/src/main/java/com/khsm/app/data/preferences/SessionStore.java new file mode 100644 index 0000000..d735754 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/preferences/SessionStore.java @@ -0,0 +1,68 @@ +package com.khsm.app.data.preferences; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.AuthManager; + +public class SessionStore { + private static final String KEY_SESSION_INFO = "KEY_SESSION_INFO"; + private static final String KEY_USER = "KEY_USER"; + + private final ISettingsGlobalStorage settingsGlobalStorage; + + public SessionStore(@NonNull Context context) { + settingsGlobalStorage = SettingsGlobalStorage.create(context); + } + + @SuppressWarnings("unused") + public boolean isAuthenticated() { + return getSessionInfo() != null; + } + + public Session getSession() { + AuthManager.SessionInfo sessionInfo = getSessionInfo(); + if (sessionInfo == null) + return null; + + User user = getUser(); + + return new Session(sessionInfo.token, user); + } + + public void setSession(@NonNull Session session) { + AuthManager.SessionInfo sessionInfo = new AuthManager.SessionInfo(session.token); + setSessionInfo(sessionInfo); + setUser(session.user); + } + + public void updateUser(@NonNull User user) { + setUser(user); + } + + public void clearSession() { + setSessionInfo(null); + setUser(null); + } + + @Nullable + private AuthManager.SessionInfo getSessionInfo() { + return settingsGlobalStorage.getObject(KEY_SESSION_INFO, AuthManager.SessionInfo.class); + } + + private void setSessionInfo(@Nullable AuthManager.SessionInfo sessionInfo) { + settingsGlobalStorage.setObject(KEY_SESSION_INFO, sessionInfo); + } + + @Nullable + private User getUser() { + return settingsGlobalStorage.getObject(KEY_USER, User.class); + } + + private void setUser(@Nullable User user) { + settingsGlobalStorage.setObject(KEY_USER, user); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/data/preferences/SettingsGlobalStorage.java b/Android/app/src/main/java/com/khsm/app/data/preferences/SettingsGlobalStorage.java new file mode 100644 index 0000000..42e55eb --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/data/preferences/SettingsGlobalStorage.java @@ -0,0 +1,59 @@ +package com.khsm.app.data.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.f2prateek.rx.preferences2.Preference; +import com.f2prateek.rx.preferences2.RxSharedPreferences; +import com.google.gson.Gson; + +public class SettingsGlobalStorage implements ISettingsGlobalStorage { + public static ISettingsGlobalStorage create(Context context) { + return new SettingsGlobalStorage(PreferenceManager.getDefaultSharedPreferences(context)); + } + + private final RxSharedPreferences sharedPreferences; + private final Gson gson; + + private SettingsGlobalStorage(SharedPreferences sharedPreferences) { + this.sharedPreferences = RxSharedPreferences.create(sharedPreferences); + this.gson = new Gson(); + } + + @Nullable + @Override + public T getObject(@NonNull String key, @NonNull Class tClass) { + return getObject(key, tClass, null); + } + + @Override + @Nullable + public T getObject(@NonNull String key, @NonNull Class tClass, @Nullable T defaultValue) { + Preference preference = sharedPreferences.getString(key); + if (!preference.isSet()) + return defaultValue; + + String valueS = preference.get(); + return gson.fromJson(valueS, tClass); + } + + @Override + public void setObject(@NonNull String key, @Nullable T value) { + Preference preference = sharedPreferences.getString(key); + + if (value != null) { + String valueS = gson.toJson(value); + preference.set(valueS); + } else { + preference.delete(); + } + } + + @Override + public void clean() { + sharedPreferences.clear(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/AuthManager.java b/Android/app/src/main/java/com/khsm/app/domain/AuthManager.java new file mode 100644 index 0000000..25cda34 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/AuthManager.java @@ -0,0 +1,75 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.entities.CreateSessionRequest; +import com.khsm.app.data.entities.CreateUserRequest; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; +import com.khsm.app.data.preferences.SessionStore; + +import io.reactivex.Completable; + +public class AuthManager { + private final Api api; + private final SessionStore sessionStore; + + public AuthManager(@NonNull Context context) { + api = ApiFactory.createApi(context); + sessionStore = new SessionStore(context); + } + + public Completable register(CreateUserRequest createUserRequest) { + return api.register(createUserRequest) + .doOnSuccess(this::authenticate) + .toCompletable(); + } + + public Completable login(CreateSessionRequest createSessionRequest) { + return api.login(createSessionRequest) + .doOnSuccess(this::authenticate) + .toCompletable(); + } + + public Completable logout() { + return Completable.create(emitter -> sessionStore.clearSession()); + } + + public Session getSession() { + return sessionStore.getSession(); + } + + private void authenticate(@NonNull Session session) { + sessionStore.setSession(session); + } + + @SuppressWarnings("WeakerAccess") + public static boolean isAdmin(@Nullable Session session) { + if (session == null) + return false; + + User user = session.user; + + if (user.roles == null) // FIXME: 16.04.2018 remove + return false; + + return user.roles.contains(User.ROLE_ADMIN); + } + + public boolean isAdmin() { + return isAdmin(getSession()); + } + + @SuppressWarnings("WeakerAccess") + public static class SessionInfo { + public final String token; + + public SessionInfo(String token) { + this.token = token; + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/DisciplinesManager.java b/Android/app/src/main/java/com/khsm/app/domain/DisciplinesManager.java new file mode 100644 index 0000000..8c15fef --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/DisciplinesManager.java @@ -0,0 +1,20 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.entities.Discipline; + +import java.util.List; + +import io.reactivex.Single; + +public class DisciplinesManager { + private final Api api; + + public DisciplinesManager(@NonNull Context context) { api = ApiFactory.createApi(context); } + + public Single> getDisciplines() { return api.getDisciplines(); } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/MeetingsManager.java b/Android/app/src/main/java/com/khsm/app/domain/MeetingsManager.java new file mode 100644 index 0000000..b32041d --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/MeetingsManager.java @@ -0,0 +1,39 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.Result; + +import java.util.List; + +import io.reactivex.Completable; +import io.reactivex.Single; + +public class MeetingsManager { + private final Api api; + + public MeetingsManager(@NonNull Context context) { + api = ApiFactory.createApi(context); + } + + public Single> getMeetings() { + return api.getMeetings(); + } + + public Single getLastMeeting() { + return api.getLastMeeting(); + } + + public Single createMeeting(Meeting meeting) { + return api.createMeeting(meeting); + } + + public Single createResult(Result result) { + return api.createResult(result); + } + +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/NewsManager.java b/Android/app/src/main/java/com/khsm/app/domain/NewsManager.java new file mode 100644 index 0000000..5b4def8 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/NewsManager.java @@ -0,0 +1,29 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.News; + +import java.util.List; + +import io.reactivex.Single; + +public class NewsManager { + private final Api api; + + public NewsManager(@NonNull Context context) { api = ApiFactory.createApi(context); } + + public Single> getNews() { return api.getNews(); } + + public Single getLastNews() { + return api.getLastNews(); + } + + public Single addNews(News news) { + return api.addNews(news); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/RankingsManager.java b/Android/app/src/main/java/com/khsm/app/domain/RankingsManager.java new file mode 100644 index 0000000..6228c4b --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/RankingsManager.java @@ -0,0 +1,21 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.api.entities.RankingsFilterInfo; +import com.khsm.app.data.entities.DisciplineResults; + +import java.util.List; + +import io.reactivex.Single; + +public class RankingsManager { + private final Api api; + + public RankingsManager(@NonNull Context context) { api = ApiFactory.createApi(context); } + + public Single> getRankings(@NonNull RankingsFilterInfo rankingsFilterInfo) { return api.getRankings(rankingsFilterInfo); } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/UserManager.java b/Android/app/src/main/java/com/khsm/app/domain/UserManager.java new file mode 100644 index 0000000..b70228e --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/domain/UserManager.java @@ -0,0 +1,61 @@ +package com.khsm.app.domain; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.khsm.app.data.api.Api; +import com.khsm.app.data.api.ApiFactory; +import com.khsm.app.data.entities.DisciplineRecord; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; +import com.khsm.app.data.preferences.SessionStore; + +import java.util.List; + +import io.reactivex.Single; + +public class UserManager { + private final Api api; + private final SessionStore sessionStore; + + public UserManager(@NonNull Context context) { + api = ApiFactory.createApi(context); + sessionStore = new SessionStore(context); + } + + @Nullable + public User getUser() { + Session session = sessionStore.getSession(); + if (session == null) + return null; + + return session.user; + } + + public Single updateUser(@NonNull User user) { + return api.updateUser(user) + .doOnSuccess(sessionStore::updateUser); + } + + public Single> getMeetingResults(int id) { + return api.getMeetingResults(id); + } + + public Single> getNewsResults(int id) { + return api.getNewsResults(id); + } + + public Single> getMyResults() { + return api.getMyResults(); + } + + public Single> getUsers() { + return api.getUsers(); + } + + public Single> getMyRecords() { + return api.getMyRecords(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/domain/UsersManager.java b/Android/app/src/main/java/com/khsm/app/domain/UsersManager.java deleted file mode 100644 index be8a683..0000000 --- a/Android/app/src/main/java/com/khsm/app/domain/UsersManager.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.khsm.app.domain; - -import com.khsm.app.data.api.entities.User; -import com.khsm.app.data.api.Api; -import com.khsm.app.data.api.ApiFactory; - -import java.util.List; - -import io.reactivex.Single; - -public class UsersManager { - private final Api api; - - public UsersManager() { - api = ApiFactory.createApi(); - } - - public Single> getUsers() { - return api.getUsers(); - } -} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/AdapterUtils.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/AdapterUtils.java new file mode 100644 index 0000000..9002e29 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/AdapterUtils.java @@ -0,0 +1,101 @@ +package com.khsm.app.presentation.ui.adapters; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Result; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.List; + +@SuppressWarnings("WeakerAccess") +public class AdapterUtils { + private static final DecimalFormat decimalFormat; + + static { + decimalFormat = new DecimalFormat("00.00"); + DecimalFormatSymbols decimalFormatSymbols = decimalFormat.getDecimalFormatSymbols(); + decimalFormatSymbols.setDecimalSeparator('.'); + decimalFormat.setDecimalFormatSymbols(decimalFormatSymbols); + } + + @Nullable + public static Float findSingle(@NonNull Result result) { + List attempts = result.attempts; + + Float best = null; + + for (int i = 0; i < attempts.size(); i++) { + Float attempt = attempts.get(i); + if (best == null || (attempt != null && best > attempt)) { + best = attempt; + } + } + + return best; + } + + @NonNull + public static String formatResultTime(@NonNull Context context, @Nullable Float time) { + if (time == null) + return context.getString(R.string.DNF); + + String result = ""; + + if (time >= 60) { + int minutes = (int)(time / 60); + result += minutes + ":"; + time -= minutes * 60; + } + + result += decimalFormat.format(time); + + return result; + } + + @NonNull + public static String formatResults(@Nullable AdapterUtils.SortMode sortMode, @NonNull Result result, @NonNull Context context) { + Float actualResult = result.average; + if (sortMode != null && sortMode.equals(AdapterUtils.SortMode.Single)) { + actualResult = AdapterUtils.findSingle(result); + } + + StringBuilder results = new StringBuilder(AdapterUtils.formatResultTime(context, actualResult)); + + List attempts = result.attempts; + if (attempts.size() > 0) { + results.append(" ("); + for (int i = 0; i < attempts.size(); i++) { + if (i > 0) + results.append(" "); + + results.append(AdapterUtils.formatResultTime(context, attempts.get(i))); + } + + int dnsCount = result.attemptCount - attempts.size(); + if (dnsCount < 0) dnsCount = 0; + + for (int i = 0; i < dnsCount; i++) { + if (attempts.size() > 0 || i > 0) { + results.append(" "); + } + results.append(context.getString(R.string.DNS)); + } + + results.append(")"); + } + + return results.toString(); + } + + public enum DisplayMode { + User, Date, UserAndDate + } + + public enum SortMode { + Average, Single + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/RecordsAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/RecordsAdapter.java new file mode 100644 index 0000000..59eeb73 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/RecordsAdapter.java @@ -0,0 +1,195 @@ +package com.khsm.app.presentation.ui.adapters; + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.RecyclerView; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.DisciplineRecord; +import com.khsm.app.data.entities.Result; + +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; + +public class RecordsAdapter extends RecyclerView.Adapter { + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()); + + private final Context context; + private final LayoutInflater inflater; + + private List disciplineRecords; + + public RecordsAdapter(@NonNull Context context) { + this.context = context; + this.inflater = LayoutInflater.from(context); + } + + public void setResults(List disciplineRecords) { + this.disciplineRecords = disciplineRecords; + notifyDataSetChanged(); + } + + @NonNull + @Override + public RecordsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.records_item, parent, false); + + ViewHolder viewHolder = new ViewHolder(view); + + SpannableString bestSingleResultDetailsString = new SpannableString(context.getString(R.string.details)); + bestSingleResultDetailsString.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + if (viewHolder.disciplineRecord != null) + bestSingleResultDetailsClick(viewHolder.disciplineRecord); + } + }, 0, bestSingleResultDetailsString.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + SpannableString bestAverageResultDetailsString = new SpannableString(context.getString(R.string.details)); + bestAverageResultDetailsString.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + if (viewHolder.disciplineRecord != null) + bestAverageResultDetailsClick(viewHolder.disciplineRecord); + } + }, 0, bestAverageResultDetailsString.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + viewHolder.bestSingleResultDetails_textView.setText(bestSingleResultDetailsString); + viewHolder.bestSingleResultDetails_textView.setMovementMethod(LinkMovementMethod.getInstance()); + viewHolder.bestAverageResultDetails_textView.setText(bestAverageResultDetailsString); + viewHolder.bestAverageResultDetails_textView.setMovementMethod(LinkMovementMethod.getInstance()); + + return viewHolder; + } + + @Override + public void onBindViewHolder(@NonNull RecordsAdapter.ViewHolder holder, int position) { + DisciplineRecord disciplineRecord = disciplineRecords.get(position); + + holder.discipline_textView.setText(disciplineRecord.discipline.name); + holder.bestSingleResult_textView.setText(formatResult(disciplineRecord.bestSingleResult, true)); + holder.bestAverageResult_textView.setText(formatResult(disciplineRecord.bestAverageResult, false)); + + holder.disciplineRecord = disciplineRecord; + } + + private String formatResult(Result result, boolean showSingle) { + if (!showSingle) { + return String.format("%s (%s)", + formatTime(result.average), + dateFormat.format(result.meeting.date) + ); + } else { + return String.format("%s (%s)", + formatTime(AdapterUtils.findSingle(result)), + dateFormat.format(result.meeting.date) + ); + } + } + + private String formatTime(Float time) { + return time != null ? String.format(Locale.ENGLISH, "%.2f", time) : context.getString(R.string.DNF); + } + + @Override + public int getItemCount() { + return disciplineRecords != null ? disciplineRecords.size() : 0; + } + + private void bestSingleResultDetailsClick(@NonNull DisciplineRecord disciplineRecord) { + showResultDetails(disciplineRecord.discipline, disciplineRecord.bestSingleResult, context.getString(R.string.Best)); + } + + private void bestAverageResultDetailsClick(@NonNull DisciplineRecord disciplineRecord) { + showResultDetails(disciplineRecord.discipline, disciplineRecord.bestAverageResult, context.getString(R.string.Average)); + } + + private void showResultDetails(Discipline discipline, Result result, String recordType) { + String title = discipline.name + " " + recordType + " (" + dateFormat.format(result.meeting.date) + ")"; + + SpannableStringBuilder message = new SpannableStringBuilder(AdapterUtils.formatResultTime(context, result.average)); + + List attempts = result.attempts; + if (attempts.size() > 0) { + Float min = null; + + for (int i = 0; i < attempts.size(); i++) { + @Nullable Float time = attempts.get(i); + + if (min == null) { + min = time; + continue; + } + + if (time != null && time < min) + min = time; + } + + message.append(" ("); + for (int i = 0; i < attempts.size(); i++) { + if (i > 0) + message.append(" "); + + Float time = attempts.get(i); + SpannableString spannableString = new SpannableString(AdapterUtils.formatResultTime(context, time)); + if (time != null && min != null && time.equals(min)) + spannableString.setSpan(new ForegroundColorSpan(Color.RED), 0, spannableString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + message.append(spannableString); + } + + int dnsCount = result.attemptCount - attempts.size(); + if (dnsCount < 0) dnsCount = 0; + + for (int i = 0; i < dnsCount; i++) { + if (attempts.size() > 0 || i > 0) { + message.append(" "); + } + message.append(context.getString(R.string.DNS)); + } + + message.append(")"); + } + + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.OK, null) + .show(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + final TextView discipline_textView; + final TextView bestSingleResult_textView; + final TextView bestAverageResult_textView; + final TextView bestSingleResultDetails_textView; + final TextView bestAverageResultDetails_textView; + + @Nullable + DisciplineRecord disciplineRecord; + + ViewHolder(View view) { + super(view); + discipline_textView = view.findViewById(R.id.discipline_textView); + bestSingleResult_textView = view.findViewById(R.id.bestSingleResult_textView); + bestAverageResult_textView = view.findViewById(R.id.bestAverageResult_textView); + bestSingleResultDetails_textView = view.findViewById(R.id.bestSingleResultDetails_textView); + bestAverageResultDetails_textView = view.findViewById(R.id.bestAverageResultDetails_textView); + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/ResultsAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/ResultsAdapter.java new file mode 100644 index 0000000..dff6bb2 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/ResultsAdapter.java @@ -0,0 +1,92 @@ +package com.khsm.app.presentation.ui.adapters; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Result; + +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; + +public class ResultsAdapter extends RecyclerView.Adapter { + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()); + + private final Context context; + + private final LayoutInflater inflater; + private final AdapterUtils.DisplayMode displayMode; + + @Nullable + private List results; + @Nullable + private AdapterUtils.SortMode sortMode; + + public ResultsAdapter(@NonNull Context context, AdapterUtils.DisplayMode displayMode) { + this.context = context; + this.inflater = LayoutInflater.from(context); + this.displayMode = displayMode; + } + + public void setResults(List results, @Nullable AdapterUtils.SortMode sortMode) { + this.results = results; + this.sortMode = sortMode; + notifyDataSetChanged(); + } + + @NonNull + @Override + public ResultsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.results_item, parent, false); + + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ResultsAdapter.ViewHolder holder, int position) { + if (results == null) + throw new RuntimeException("results should not be null"); + + Result result = results.get(position); + + String title; + if (displayMode.equals(AdapterUtils.DisplayMode.User)) { + title = (position + 1) + " " + result.user.firstName + " " + result.user.lastName; + } else if (displayMode.equals(AdapterUtils.DisplayMode.Date)) { + title = dateFormat.format(result.meeting.date); + } else if (displayMode.equals(AdapterUtils.DisplayMode.UserAndDate)) { + title = (position + 1) + " " + result.user.firstName + " " + result.user.lastName + " (" + dateFormat.format(result.meeting.date) + ")"; + } else { + throw new RuntimeException("Not supported display mode"); + } + holder.title.setText(title); + + holder.results.setText(AdapterUtils.formatResults(sortMode, result, context)); + } + + @Override + public int getItemCount() { + if (results == null) + return 0; + + return results.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + final TextView title; + final TextView results; + + ViewHolder(View view) { + super(view); + title = view.findViewById(R.id.title); + results = view.findViewById(R.id.results); + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/UserListAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/UserListAdapter.java deleted file mode 100644 index 07f1c6c..0000000 --- a/Android/app/src/main/java/com/khsm/app/presentation/ui/adapters/UserListAdapter.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.khsm.app.presentation.ui.adapters; - -import android.content.Context; -import android.support.v7.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.khsm.app.R; -import com.khsm.app.data.api.entities.User; - -import java.util.List; - -public class UserListAdapter extends RecyclerView.Adapter { - private final LayoutInflater inflater; - - private final List users; - - public UserListAdapter(Context context, List users) { - this.users = users; - this.inflater = LayoutInflater.from(context); - } - - @Override - public UserListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = inflater.inflate(R.layout.list_item, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(UserListAdapter.ViewHolder holder, int position) { - User user = users.get(position); - - holder.studentId.setText(String.valueOf(user.getId())); // тут нельзя сестить инт, надо стринг! он думает что инт это ресурс - holder.studentName.setText(user.getFirstName()); - } - - @Override - public int getItemCount() { - return users.size(); - } - - class ViewHolder extends RecyclerView.ViewHolder { - final TextView studentId; - final TextView studentName; - - ViewHolder(View itemView) { - super(itemView); - - studentId = itemView.findViewById(R.id.id); - studentName = itemView.findViewById(R.id.firstName); - } - } -} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/MainActivity.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/MainActivity.java index 4255f33..47af046 100644 --- a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/MainActivity.java +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/MainActivity.java @@ -1,75 +1,160 @@ package com.khsm.app.presentation.ui.screens; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; -import android.support.v7.app.AlertDialog; +import android.support.design.widget.NavigationView; +import android.support.v4.app.Fragment; +import android.support.v4.widget.DrawerLayout; import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; - -import com.khsm.app.presentation.ui.adapters.UserListAdapter; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; import com.khsm.app.R; -import com.khsm.app.data.api.entities.User; -import com.khsm.app.domain.UsersManager; - -import java.util.List; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; +import com.khsm.app.data.entities.Gender; +import com.khsm.app.data.entities.Session; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.presentation.ui.screens.auth.LoginActivity; +import com.khsm.app.presentation.ui.screens.disciplines.DisciplineListFragment; +import com.khsm.app.presentation.ui.screens.meetings.MeetingListFragment; +import com.khsm.app.presentation.ui.screens.meetings.MeetingResultsFragment; +import com.khsm.app.presentation.ui.screens.news.NewsFragment; +import com.khsm.app.presentation.ui.screens.profile.EditProfileFragment; +import com.khsm.app.presentation.ui.screens.rankings.RankingsFragment; public class MainActivity extends AppCompatActivity { - private CompositeDisposable disposable; + public static Intent newIntent(Context context, boolean clearTask) { + Intent intent = new Intent(context, MainActivity.class); + if (clearTask) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); + } + return intent; + } - private UsersManager usersManager; + private AuthManager authManager; - private RecyclerView recyclerView; + private DrawerLayout drawerLayout; + + @SuppressWarnings("FieldCanBeLocal") + private ImageView avatar_imageView; + private TextView userName_textView; + private MenuItem myProfileMenuItem; + @SuppressWarnings("FieldCanBeLocal") + private MenuItem loginMenuItem; + @SuppressWarnings("FieldCanBeLocal") + private MenuItem logoutMenuItem; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - disposable = new CompositeDisposable(); - - usersManager = new UsersManager(); - - recyclerView = findViewById(R.id.recyclerView); - recyclerView.setLayoutManager(new LinearLayoutManager(this)); - - loadUsers(); + authManager = new AuthManager(this); + + drawerLayout = findViewById(R.id.drawer_layout); + + NavigationView navigationView = findViewById(R.id.nav_view); + View headerView = navigationView.getHeaderView(0); + + Menu navigationViewMenu = navigationView.getMenu(); + + myProfileMenuItem = navigationViewMenu.findItem(R.id.my_profile); + loginMenuItem = navigationViewMenu.findItem(R.id.login); + logoutMenuItem = navigationViewMenu.findItem(R.id.logout); + + avatar_imageView = headerView.findViewById(R.id.avatar_imageView); + userName_textView = headerView.findViewById(R.id.userName); + + navigationView.setNavigationItemSelectedListener(onNavigationItemSelectedListener); + + Session session = authManager.getSession(); + + if (session == null) { + userName_textView.setText(R.string.Main_NoAccount); + myProfileMenuItem.setVisible(false); + loginMenuItem.setVisible(true); + logoutMenuItem.setVisible(false); + } else { + User user = session.user; + myProfileMenuItem.setVisible(true); + userName_textView.setText(user.firstName + " " + user.lastName); + loginMenuItem.setVisible(false); + logoutMenuItem.setVisible(true); + } + + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.content, MeetingResultsFragment.newInstance(null)) + .commit(); + } + + // TODO: 26.02.2018 fix this temporary implementation + if (session != null && session.user.gender == Gender.FEMALE) + { + Glide.with(this) + .load(R.drawable.avatar_female) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } else if (session != null && session.user.gender == Gender.MALE) { + Glide.with(this) + .load(R.drawable.avatar_male) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } else if (session == null) { + Glide.with(this) + .load(R.drawable.avatar_nobody) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } } - @Override - protected void onDestroy() { - super.onDestroy(); - - disposable.clear(); - } - - private void loadUsers() { - Disposable disposable = usersManager.getUsers() // получить операцию по загрузке пользователей из сети - .subscribeOn(Schedulers.io()) // установить чтобы запрос выполнятся не в главном потоке а асинхронно - .observeOn(AndroidSchedulers.mainThread()) // установить чтобы ответ вернулся в главном потоке - .subscribe( // обработать ответ - this::setUsers, // ссылка на метод, метод будет вызван при успешном ответе сервера - this::handleError // ссылка на метод для обработки ошибки - ); - this.disposable.add(disposable); // добавление операции в массив, все операции будут отмененты при закрытии окна + private NavigationView.OnNavigationItemSelectedListener onNavigationItemSelectedListener = new NavigationView.OnNavigationItemSelectedListener() { + @Override + public boolean onNavigationItemSelected(MenuItem menuItem) { + menuItem.setChecked(true); + + if (menuItem.getItemId() == R.id.last_meeting) { + replaceFragment(MeetingResultsFragment.newInstance(null)); + } else if (menuItem.getItemId() == R.id.meetings) { + replaceFragment(MeetingListFragment.newInstance()); + } else if (menuItem.getItemId() == R.id.disciplines) { + replaceFragment(DisciplineListFragment.newInstance()); + } else if (menuItem.getItemId() == R.id.login) { + startActivity(LoginActivity.newIntent(MainActivity.this)); + } else if (menuItem.getItemId() == R.id.my_profile) { + replaceFragment(EditProfileFragment.newInstance()); + } else if (menuItem.getItemId() == R.id.Rankings) { + replaceFragment(RankingsFragment.newInstance()); + } else if (menuItem.getItemId() == R.id.news) { + replaceFragment(NewsFragment.newInstance()); + } else if (menuItem.getItemId() == R.id.logout) { + authManager.logout().subscribe(); + startActivity(MainActivity.newIntent(MainActivity.this, true)); + } + + drawerLayout.closeDrawers(); + + return true; + } + }; + + public void replaceFragment(Fragment fragment) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.content, fragment) + .addToBackStack(null) + .commit(); } - private void setUsers(List users) { - UserListAdapter adapter = new UserListAdapter(this, users); - recyclerView.setAdapter(adapter); - } - - private void handleError(Throwable throwable) { - throwable.printStackTrace(); - - new AlertDialog.Builder(this) - .setTitle(R.string.Error) - .setMessage(throwable.getMessage()) - .setPositiveButton(R.string.OK, null) - .show(); + public void showMenu() { + drawerLayout.openDrawer(Gravity.LEFT, true); } } diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/LoginActivity.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/LoginActivity.java new file mode 100644 index 0000000..9ed0603 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/LoginActivity.java @@ -0,0 +1,162 @@ +package com.khsm.app.presentation.ui.screens.auth; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.CreateSessionRequest; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class LoginActivity extends AppCompatActivity { + public static Intent newIntent(Context context) { + return new Intent(context, LoginActivity.class); + } + + private TextView createAnAccount_textView; + private EditText email; + private EditText password; + @SuppressWarnings("FieldCanBeLocal") + private Button login; + + private Toolbar toolbar; + + @Nullable + private ProgressDialog progressDialog; + + private AuthManager authManager; + + private Disposable loginDisposable; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.login_activity); + + toolbar = findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onBackPressed(); + } + }); + + authManager = new AuthManager(this); + + createAnAccount_textView = findViewById(R.id.createAnAccount_textView); + + email = findViewById(R.id.email); + password = findViewById(R.id.password); + login = findViewById(R.id.login); + + login.setOnClickListener(view -> login()); + + SpannableString createAnAccount = new SpannableString(getString(R.string.Auth_Create_an_account)); + createAnAccount.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + showRegisterActivity(); + } + }, 0, createAnAccount.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + createAnAccount_textView.setText(createAnAccount); + createAnAccount_textView.setMovementMethod(LinkMovementMethod.getInstance()); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + } + + private void login() { + if (email.length() < 1 || password.length() < 1) { + showErrorMessage(getString(R.string.Login_Error_CheckInputData)); + return; + } + + CreateSessionRequest createSessionRequest = new CreateSessionRequest( + email.getText().toString(), + password.getText().toString() + ); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + progressDialog = ProgressDialog.show(this, null, getString(R.string.Please_WaitD3), true, false); + + if (loginDisposable != null) { + loginDisposable.dispose(); + loginDisposable = null; + } + + loginDisposable = authManager.login(createSessionRequest) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::loginCompleted, + this::handleError + ); + + } + + private void loginCompleted() { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + startActivity(MainActivity.newIntent(this, true)); + } + + private void handleError(Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(this) + .setTitle(R.string.Error) + .setMessage(R.string.Login_Error_Authentication) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(this) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void showRegisterActivity() { + startActivity(RegisterActivity.newIntent(this)); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/RegisterActivity.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/RegisterActivity.java new file mode 100644 index 0000000..4f30f3c --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/auth/RegisterActivity.java @@ -0,0 +1,172 @@ +package com.khsm.app.presentation.ui.screens.auth; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.RadioButton; + +import com.khsm.app.R; +import com.khsm.app.data.entities.CreateUserRequest; +import com.khsm.app.data.entities.Gender; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class RegisterActivity extends AppCompatActivity { + @SuppressWarnings("unused") + public static Intent newIntent(Context context) { + return new Intent(context, RegisterActivity.class); + } + + @SuppressWarnings("FieldCanBeLocal") + private Button registerButton; + private EditText firstName; + private EditText lastName; + private EditText email; + private EditText password; + private EditText confirmPassword; + private RadioButton male; + private RadioButton female; + + private Toolbar toolbar; + + @Nullable + private Disposable registerDisposable; + + private AuthManager authManager; + + @Nullable + private ProgressDialog progressDialog; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.register_activity); + + toolbar = findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onBackPressed(); + } + }); + + authManager = new AuthManager(this); + + registerButton = findViewById(R.id.registerButton); + firstName = findViewById(R.id.first_name); + lastName = findViewById(R.id.last_name); + email = findViewById(R.id.email); + password = findViewById(R.id.password); + confirmPassword = findViewById(R.id.confirm_password); + male = findViewById(R.id.male); + female = findViewById(R.id.female); + + registerButton.setOnClickListener(view -> register()); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + } + + private void register() { + if (firstName.length() < 1 + || lastName.length() < 1 + || email.length() < 1 + || password.length() < 1 + || confirmPassword.length() < 1 + || (!male.isChecked() && !female.isChecked())) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + Gender gender = + male.isChecked() ? Gender.MALE : + female.isChecked() ? Gender.FEMALE : + null; + + CreateUserRequest createUserRequest = new CreateUserRequest( + new User( + firstName.getText().toString(), + lastName.getText().toString(), + gender, + email.getText().toString() + ), + password.getText().toString() + ); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + progressDialog = ProgressDialog.show(this, null, getString(R.string.Please_WaitD3), true, false); + + if (registerDisposable != null) { + registerDisposable.dispose(); + registerDisposable = null; + } + + registerDisposable = authManager.register(createUserRequest) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::registrationCompleted, + this::handleError + ); + + } + + private void registrationCompleted() { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + startActivity(MainActivity.newIntent(this, true)); + } + + private void handleError(Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(this) + .setTitle(R.string.Error) + .setMessage(R.string.Register_Error_UserRegisterError) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(this) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void showLoginActivity() { + startActivity(LoginActivity.newIntent(this)); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineDetailsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineDetailsFragment.java new file mode 100644 index 0000000..fe4868e --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineDetailsFragment.java @@ -0,0 +1,70 @@ +package com.khsm.app.presentation.ui.screens.disciplines; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.widget.Toolbar; +import android.text.method.ScrollingMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.presentation.ui.screens.MainActivity; + +public class DisciplineDetailsFragment extends Fragment { + private static final String KEY_DISCIPLINE = "KEY_DISCIPLINE"; + + public static DisciplineDetailsFragment newInstance(Discipline discipline) { + DisciplineDetailsFragment fragment = new DisciplineDetailsFragment(); + + Bundle arguments = new Bundle(); + arguments.putSerializable(KEY_DISCIPLINE, discipline); + + fragment.setArguments(arguments); + + return fragment; + } + + private Discipline discipline; + + private Toolbar toolbar; + private TextView disciplineDetails; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle arguments = getArguments(); + + discipline = (Discipline) arguments.getSerializable(KEY_DISCIPLINE); + } + + + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.discipline_details_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setTitle(discipline.name); + + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + disciplineDetails = view.findViewById(R.id.disciplineDetails); + disciplineDetails.setText(discipline.description); + disciplineDetails.setMovementMethod(new ScrollingMovementMethod()); + + return view; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListAdapter.java new file mode 100644 index 0000000..5b2b00f --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListAdapter.java @@ -0,0 +1,76 @@ +package com.khsm.app.presentation.ui.screens.disciplines; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.annotations.Nullable; + +public class DisciplineListAdapter extends RecyclerView.Adapter { + private DisciplineListFragment disciplineListFragment; + + private LayoutInflater inflater; + private List disciplines; + + DisciplineListAdapter(Context context, DisciplineListFragment disciplineListFragment) { + this.disciplines = new ArrayList<>(); + + this.disciplineListFragment = disciplineListFragment; + + this.inflater = LayoutInflater.from(context); + } + + public void setDisciplines(List disciplines) { + this.disciplines = disciplines; + notifyDataSetChanged(); + } + + @Override + public DisciplineListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.disciplines_list_item, parent, false); + + ViewHolder viewHolder = new ViewHolder(view); + + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Discipline discipline = viewHolder.discipline; + if (discipline == null) + return; + disciplineListFragment.onItemClicked(discipline); + } + }); + + return viewHolder; + } + + @Override + public void onBindViewHolder(DisciplineListAdapter.ViewHolder holder, int position) { + Discipline discipline = disciplines.get(position); + holder.disciplineName.setText(discipline.name); + holder.discipline = discipline; + } + + @Override + public int getItemCount() { + return disciplines.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + final TextView disciplineName; + @Nullable Discipline discipline; + ViewHolder(View view){ + super(view); + disciplineName = view.findViewById(R.id.disciplineName); + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListFragment.java new file mode 100644 index 0000000..00a6554 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/disciplines/DisciplineListFragment.java @@ -0,0 +1,126 @@ +package com.khsm.app.presentation.ui.screens.disciplines; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.domain.DisciplinesManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class DisciplineListFragment extends Fragment { + public static DisciplineListFragment newInstance() { + return new DisciplineListFragment(); + } + + private DisciplinesManager disciplinesManager; + + private DisciplineListAdapter adapter; + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private Toolbar toolbar; + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + private ProgressBar progressBar; + + @Nullable + private Disposable loadDisposable; + + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + disciplinesManager = new DisciplinesManager(requireContext()); + + adapter = new DisciplineListAdapter(getContext(), this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (loadDisposable != null) { + loadDisposable.dispose(); + loadDisposable = null; + } + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.disciplines_list_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + loadDisciplines(); + + return view; + } + + private void loadDisciplines() { + progressBar.setVisibility(View.VISIBLE); + + if (loadDisposable != null) { + loadDisposable.dispose(); + loadDisposable = null; + } + + loadDisposable = disciplinesManager.getDisciplines() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplines, + this::handleError + ); + } + + private void setDisciplines(List disciplines) { + progressBar.setVisibility(View.INVISIBLE); + + adapter.setDisciplines(disciplines); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + public void onItemClicked(@NonNull Discipline discipline) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(DisciplineDetailsFragment.newInstance(discipline)); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddMeetingFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddMeetingFragment.java new file mode 100644 index 0000000..3eec439 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddMeetingFragment.java @@ -0,0 +1,163 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.app.ProgressDialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.domain.MeetingsManager; +import com.khsm.app.presentation.ui.screens.MainActivity; +import com.khsm.app.presentation.ui.utils.maskedittext.EditTextMask; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.text.ParseException; +import java.util.Locale; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class AddMeetingFragment extends Fragment{ + public static AddMeetingFragment newInstance() { + return new AddMeetingFragment(); + } + + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy", Locale.getDefault()); + + private Toolbar toolbar; + private EditText meetingNumber; + private EditText meetingDate; + private Button done; + + private MeetingsManager meetingsManager; + + @Nullable + private ProgressDialog progressDialog; + + @Nullable + private Disposable createMeetingDisposable; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + meetingsManager = new MeetingsManager(requireContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.add_meeting_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + meetingDate = view.findViewById(R.id.meeting_date); + EditTextMask.setup(meetingDate, "##-##-####"); + meetingNumber = view.findViewById(R.id.meeting_number); + + done = view.findViewById(R.id.doneButton); + done.setOnClickListener(cm -> createMeeting()); + return view; + } + + public void createMeeting() { + if (meetingNumber.length() < 1 + || meetingDate.length() < 1) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + Date meetingDate; + try { + meetingDate = stringToJavaDate(this.meetingDate.getText().toString()); + } catch (ParseException e) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + Meeting meeting = new Meeting( + Integer.parseInt(meetingNumber.getText().toString()), + meetingDate + ); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + progressDialog = ProgressDialog.show(requireContext(), null, getString(R.string.Please_WaitD3), true, false); + + if (createMeetingDisposable != null) { + createMeetingDisposable.dispose(); + createMeetingDisposable = null; + } + + createMeetingDisposable = meetingsManager.createMeeting(meeting) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::creatingDone, + this::handleError + ); + } + + private void creatingDone(Meeting meeting) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(AddResultsFragment.newInstance(meeting)); + + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void handleError(Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(R.string.AddMeeting_Error_MeetingCreationError) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private java.util.Date stringToJavaDate(@NonNull String dateString) throws ParseException { + dateString = dateString.trim(); + + if (dateString.isEmpty()) + return null; + + return dateFormat.parse(dateString); + } + +} + diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddResultsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddResultsFragment.java new file mode 100644 index 0000000..80481c7 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/AddResultsFragment.java @@ -0,0 +1,269 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.app.ProgressDialog; +import android.support.v4.app.Fragment; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.view.ViewCompat; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.Result; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.DisciplinesManager; +import com.khsm.app.domain.MeetingsManager; +import com.khsm.app.domain.UserManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + + +public class AddResultsFragment extends Fragment { + public static AddResultsFragment newInstance(Meeting meeting) { + AddResultsFragment fragment = new AddResultsFragment(); + fragment.meeting = meeting; + return fragment; + } + + private Toolbar toolbar; + private EditText results; + private Button done; + private Button add; + private Spinner spinnerDisciplines; + private Spinner spinnerUsers; + private TextView meetingNumber; + private TextView tvDisciplines; + private TextView tvUsers; + private TextView tvWriteResults; + + private Meeting meeting; + + private DisciplinesSpinnerAdapter disciplinesSpinnerAdapter; + private UsersSpinnerAdapter usersSpinnerAdapter; + private ProgressBar progressBar; + + @Nullable + private ProgressDialog progressDialog; + + @Nullable + private Disposable loadDisposable; + + private DisciplinesManager disciplinesManager; + private UserManager userManager; + private MeetingsManager meetingsManager; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + userManager = new UserManager(requireContext()); + disciplinesManager = new DisciplinesManager(requireContext()); + meetingsManager = new MeetingsManager(requireContext()); + + disciplinesSpinnerAdapter = new DisciplinesSpinnerAdapter(getContext(), this); + usersSpinnerAdapter = new UsersSpinnerAdapter(getContext(), this); + } + + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.add_results_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + results = view.findViewById(R.id.results); + + progressBar = ViewCompat.requireViewById(view, R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + spinnerDisciplines = view.findViewById(R.id.spinnerDisciplines); + loadDisciplinesToSpinner(); + spinnerDisciplines.setAdapter(disciplinesSpinnerAdapter); + + spinnerUsers = view.findViewById(R.id.spinnerUsers); + loadUsersToSpinner(); + spinnerUsers.setAdapter(usersSpinnerAdapter); + + meetingNumber = view.findViewById(R.id.tvTitle); + tvDisciplines = view.findViewById(R.id.tvDisciplines); + tvUsers = view.findViewById(R.id.tvUsers); + tvWriteResults = view.findViewById(R.id.tvAddResults); + + done = view.findViewById(R.id.doneButton); + View.OnClickListener doneClicked = new View.OnClickListener() { + @Override + public void onClick(View view) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(MeetingListFragment.newInstance()); + } + }; + done.setOnClickListener(doneClicked); + + add = view.findViewById(R.id.buttonAdd); + add.setOnClickListener(cr -> createResult()); + return view; + } + + public void createResult() { + if (results.length() < 1) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + Discipline discipline = (Discipline) spinnerDisciplines.getSelectedItem(); + User user = (User) spinnerUsers.getSelectedItem(); + + String resultsString = results.getText().toString(); + List attempts; + attempts = parseStringToFloatArray(resultsString); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + progressDialog = ProgressDialog.show(requireContext(), null, getString(R.string.Please_WaitD3), true, false); + + if (loadDisposable != null) { + loadDisposable.dispose(); + loadDisposable = null; + } + + Result result = new Result(meeting, user, attempts, discipline); + + loadDisposable = meetingsManager.createResult(result) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::creatingDone, + this::handleCreatingError + ); + } + + private void creatingDone(Result result) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + results.setText(null); + } + + private void handleCreatingError(Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(R.string.AddResult_Error_ResultCreationError) + .setPositiveButton(R.string.OK, null) + .show(); + } + + public List parseStringToFloatArray(String results) { + String[] array; + array = results.split("\n"); + List attempts = new ArrayList<>(); + for (String a : array) { + if (a.equals("DNF") || a.equals("dnf") || a.equals("ДНФ") || a.equals("днф") || a.equals("-")) { + attempts.add(null); + } + else { + try { + attempts.add(Float.parseFloat(a)); + } catch (Exception e) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return null; + } + } + } + return attempts; + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void loadDisciplinesToSpinner() { + progressBar.setVisibility(View.VISIBLE); + + disciplinesManager.getDisciplines() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplines, + this::handleError + ); + } + + private void setDisciplines(List disciplines) { + progressBar.setVisibility(View.INVISIBLE); + + disciplinesSpinnerAdapter.setDisciplines(disciplines); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void loadUsersToSpinner() { + progressBar.setVisibility(View.VISIBLE); + + userManager.getUsers() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setUsers, + this::handleError + ); + } + + private void setUsers(List users) { + progressBar.setVisibility(View.INVISIBLE); + + usersSpinnerAdapter.setUsers(users); + } + +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/DisciplinesSpinnerAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/DisciplinesSpinnerAdapter.java new file mode 100644 index 0000000..c79381f --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/DisciplinesSpinnerAdapter.java @@ -0,0 +1,64 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; + +import java.util.ArrayList; +import java.util.List; + +public class DisciplinesSpinnerAdapter extends BaseAdapter { + private List disciplines; + private LayoutInflater inflater; + private AddResultsFragment addResultsFragment; + + DisciplinesSpinnerAdapter(Context context, AddResultsFragment addResultsFragment) { + this.disciplines = new ArrayList<>(); + + this.addResultsFragment = addResultsFragment; + + this.inflater = LayoutInflater.from(context); + } + + public void setDisciplines(List disciplines) { + this.disciplines = disciplines; + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return disciplines.size(); + } + + @Override + public Object getItem(int position) { + return disciplines.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = inflater.inflate(R.layout.disciplines_spinner_item, parent, false); + } + + Discipline discipline = getDiscipline(position); + ((TextView)view.findViewById(R.id.item)).setText(discipline.name); + return view; + } + + Discipline getDiscipline(int position) { + return ((Discipline) getItem(position)); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListAdapter.java new file mode 100644 index 0000000..be63c5e --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListAdapter.java @@ -0,0 +1,80 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Meeting; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import io.reactivex.annotations.Nullable; + +public class MeetingListAdapter extends RecyclerView.Adapter { + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()); + + private MeetingListFragment meetingListFragment; + + private LayoutInflater inflater; + private List meetings; + + MeetingListAdapter(Context context, MeetingListFragment meetingListFragment) { + this.meetings = new ArrayList<>(); + + this.meetingListFragment = meetingListFragment; + + this.inflater = LayoutInflater.from(context); + } + + public void setMeetings(List meetings) { + this.meetings = meetings; + notifyDataSetChanged(); + } + + @Override + public MeetingListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.meetings_list_item, parent, false); + + ViewHolder viewHolder = new ViewHolder(view); + + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Meeting meeting = viewHolder.meeting; + if (meeting == null) + return; + meetingListFragment.onItemClicked(meeting); + } + }); + + return viewHolder; + } + + @Override + public void onBindViewHolder(MeetingListAdapter.ViewHolder holder, int position) { + Meeting meeting = meetings.get(position); + holder.meetingDate.setText(dateFormat.format(meeting.date) + " (" + meeting.number + ")"); + holder.meeting = meeting; + } + + @Override + public int getItemCount() { + return meetings.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + final TextView meetingDate; + @Nullable Meeting meeting; + ViewHolder(View view){ + super(view); + meetingDate = view.findViewById(R.id.meetingDate); + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListFragment.java new file mode 100644 index 0000000..85d5cee --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingListFragment.java @@ -0,0 +1,182 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.FloatingActionButton; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.Session; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.domain.MeetingsManager; +import com.khsm.app.presentation.ui.screens.MainActivity; +import com.khsm.app.presentation.ui.screens.disciplines.DisciplineDetailsFragment; +import com.khsm.app.presentation.ui.screens.disciplines.DisciplineListFragment; + +import java.util.List; +import java.util.ListIterator; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class MeetingListFragment extends Fragment implements MenuItem.OnMenuItemClickListener { + public static MeetingListFragment newInstance() { + return new MeetingListFragment(); + } + + private MeetingsManager meetingsManager; + private AuthManager authManager; + @Nullable + private Disposable loadDisposable; + + private MeetingListAdapter adapter; + + @SuppressWarnings("FieldCanBeLocal") + private Toolbar toolbar; + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + private ProgressBar progressBar; + private FloatingActionButton faButton; + private MenuItem disciplines_menuItem; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + meetingsManager = new MeetingsManager(requireContext()); + + adapter = new MeetingListAdapter(getContext(), this); + + authManager = new AuthManager(getContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.meetings_list_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + Menu menu = toolbar.getMenu(); + + disciplines_menuItem = menu.add(R.string.Disciplines); + disciplines_menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + disciplines_menuItem.setOnMenuItemClickListener(this); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + faButton = view.findViewById(R.id.fabutton); + if (authManager.isAdmin()) { + faButton.setVisibility(View.VISIBLE); + } else { + faButton.setVisibility(View.INVISIBLE); + } + + View.OnClickListener faClicked = new View.OnClickListener() { + @Override + public void onClick(View view) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(AddMeetingFragment.newInstance()); + } + }; + + faButton.setOnClickListener(faClicked); + return view; + } + + @Override + public void onStart() { + super.onStart(); + + loadMeetings(); + } + + @Override + public void onStop() { + super.onStop(); + + cancelLoadOperation(); + } + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void loadMeetings() { + progressBar.setVisibility(View.VISIBLE); + + cancelLoadOperation(); + loadDisposable = meetingsManager.getMeetings() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setMeetings, + this::handleError + ); + } + + private void setMeetings(List meetings) { + progressBar.setVisibility(View.INVISIBLE); + + adapter.setMeetings(meetings); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + public void onItemClicked(@NonNull Meeting meeting) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(MeetingResultsFragment.newInstance(meeting)); + } + + private void showDisciplineList() { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(DisciplineListFragment.newInstance()); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + if (item == disciplines_menuItem) { + showDisciplineList(); + return true; + } + + return false; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingResultsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingResultsFragment.java new file mode 100644 index 0000000..3f6d623 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/MeetingResultsFragment.java @@ -0,0 +1,350 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.FloatingActionButton; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.print.PrintHelper; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.data.entities.Meeting; +import com.khsm.app.data.entities.Result; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.domain.MeetingsManager; +import com.khsm.app.domain.UserManager; +import com.khsm.app.presentation.ui.adapters.AdapterUtils; +import com.khsm.app.presentation.ui.adapters.ResultsAdapter; +import com.khsm.app.presentation.ui.screens.MainActivity; +import com.khsm.app.presentation.ui.utils.PrintBitmapBuilder; + +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class MeetingResultsFragment extends Fragment implements MenuItem.OnMenuItemClickListener { + private static final String KEY_MEETING = "KEY_MEETING"; + public static final String ROLE_ADMIN = "Admin"; + + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()); + + public static MeetingResultsFragment newInstance(Meeting meeting) { + MeetingResultsFragment fragment = new MeetingResultsFragment(); + + Bundle arguments = new Bundle(); + arguments.putSerializable(KEY_MEETING, meeting); + + fragment.setArguments(arguments); + + return fragment; + } + + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + + private ProgressBar progressBar; + + @Nullable + private Disposable loadDisposable; + + private MeetingsManager meetingsManager; + private UserManager userManager; + private AuthManager authManager; + + private Meeting meeting; + @Nullable + private List disciplineResults; + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private Toolbar toolbar; + + private TabLayout tabLayout; + + private ResultsAdapter adapter; + + @SuppressWarnings("FieldCanBeLocal") + private boolean lastMeetingMode; + + private FloatingActionButton faButton; + + @Nullable + private MenuItem meetings_menuItem; + private MenuItem print_menuItem; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Context context = requireContext(); + + meetingsManager = new MeetingsManager(context); + userManager = new UserManager(context); + authManager = new AuthManager(context); + + adapter = new ResultsAdapter(context, AdapterUtils.DisplayMode.User); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + // init view + View view = inflater.inflate(R.layout.meeting_results_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + Menu menu = toolbar.getMenu(); + + meetings_menuItem = menu.add(R.string.Meetings); + meetings_menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + meetings_menuItem.setOnMenuItemClickListener(this); + meetings_menuItem.setVisible(false); + + print_menuItem = menu.add(R.string.Print); + print_menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + print_menuItem.setOnMenuItemClickListener(this); + + tabLayout = view.findViewById(R.id.tabLayout); + tabLayout.setVisibility(View.INVISIBLE); + tabLayout.addOnTabSelectedListener(onTabSelectedListener); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + faButton = view.findViewById(R.id.fabutton); + if (authManager.isAdmin()) { + faButton.setVisibility(View.VISIBLE); + } else { + faButton.setVisibility(View.INVISIBLE); + } + + View.OnClickListener faClicked = new View.OnClickListener() { + @Override + public void onClick(View view) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(AddResultsFragment.newInstance(meeting)); + } + }; + + faButton.setOnClickListener(faClicked); + return view; + } + + @Override + public void onStart() { + super.onStart(); + + Bundle arguments = getArguments(); + if (arguments == null) + throw new RuntimeException("Arguments should be provided"); + + Meeting meeting = (Meeting) arguments.getSerializable(KEY_MEETING); + + lastMeetingMode = meeting == null; + + if (lastMeetingMode) { + //noinspection ConstantConditions + meetings_menuItem.setVisible(true); + } + + if (!lastMeetingMode) { + //noinspection ConstantConditions + setMeeting(meeting); + } else { + loadLastMeeting(); + } + } + + public boolean isAdmin(List roles) { + return roles.contains(ROLE_ADMIN); + } + + @Override + public void onStop() { + super.onStop(); + + cancelLoadOperation(); + } + + private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + DisciplineResults disciplineResults = (DisciplineResults) tab.getTag(); + if (disciplineResults != null) { + setDisciplineResults(disciplineResults); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + } + }; + + private void setMeeting(Meeting meeting) { + this.meeting = meeting; + + progressBar.setVisibility(View.VISIBLE); + + toolbar.setTitle(dateFormat.format(meeting.date) + " (" + meeting.number + ")"); + + loadResults(); + } + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void loadLastMeeting() { + toolbar.setTitle(R.string.Last_Meeting); + + progressBar.setVisibility(View.VISIBLE); + + cancelLoadOperation(); + loadDisposable = meetingsManager.getLastMeeting() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setMeeting, + this::handleError + ); + } + + private void loadResults() { + progressBar.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.INVISIBLE); + + cancelLoadOperation(); + loadDisposable = userManager.getMeetingResults(meeting.id) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplineResults, + this::handleError + ); + } + + private void setDisciplineResults(List disciplineResults) { + this.disciplineResults = disciplineResults; + + progressBar.setVisibility(View.INVISIBLE); + + tabLayout.removeAllTabs(); + + for (DisciplineResults disciplineResult : disciplineResults) { + tabLayout.addTab(tabLayout.newTab().setText(disciplineResult.discipline.name).setTag(disciplineResult)); + } + + boolean showTabs = !disciplineResults.isEmpty(); + tabLayout.setVisibility(showTabs ? View.VISIBLE : View.INVISIBLE); + if (showTabs) { + tabLayout.setAlpha(0); + tabLayout.animate().alpha(1).setDuration(300).start(); + recyclerView.setAlpha(0); + recyclerView.animate().alpha(1).setDuration(300).start(); + } + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void setDisciplineResults(@NonNull DisciplineResults disciplineResults) { + adapter.setResults(disciplineResults.results, null); + } + + private void showMeetingList() { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(MeetingListFragment.newInstance()); + } + + private void print() { + Context context = requireContext(); + + @Nullable List disciplineResults = this.disciplineResults; + if (disciplineResults == null) + return; + + PrintBitmapBuilder builder = new PrintBitmapBuilder(context); + + StringBuilder sb = new StringBuilder(); + sb.append(dateFormat.format(meeting.date)).append(" (").append(meeting.number).append(")\n\n"); + builder.setTextAlign(PrintBitmapBuilder.ReceiptTextAlign.CENTER); + builder.appendString(sb.toString()); + + sb = new StringBuilder(); + + for (DisciplineResults disciplineResult : disciplineResults) { + sb.append("--- ").append(disciplineResult.discipline.name).append(" ---").append("\n"); + + for (int i = 0; i < disciplineResult.results.size(); i++) { + Result result = disciplineResult.results.get(i); + User user = result.user; + sb.append(i + 1).append(". ").append(user.firstName).append(" ").append(user.lastName).append(" "); + sb.append(AdapterUtils.formatResults(AdapterUtils.SortMode.Average, result, context)).append("\n"); + } + + sb.append("\n"); + } + + builder.setTextAlign(PrintBitmapBuilder.ReceiptTextAlign.LEFT); + builder.appendString(sb.toString()); + + PrintHelper printHelper = new PrintHelper(context); + printHelper.printBitmap("Print", builder.build()); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + if (item == meetings_menuItem) { + showMeetingList(); + return true; + } else if (item == print_menuItem) { + print(); + return true; + } + + return false; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/UsersSpinnerAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/UsersSpinnerAdapter.java new file mode 100644 index 0000000..4f4d955 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/meetings/UsersSpinnerAdapter.java @@ -0,0 +1,65 @@ +package com.khsm.app.presentation.ui.screens.meetings; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import com.khsm.app.R; +import com.khsm.app.data.entities.Discipline; +import com.khsm.app.data.entities.User; + +import java.util.ArrayList; +import java.util.List; + +public class UsersSpinnerAdapter extends BaseAdapter { + private List users; + private LayoutInflater inflater; + private AddResultsFragment addResultsFragment; + + UsersSpinnerAdapter(Context context, AddResultsFragment addResultsFragment) { + this.users = new ArrayList<>(); + + this.addResultsFragment = addResultsFragment; + + this.inflater = LayoutInflater.from(context); + } + + public void setUsers(List users) { + this.users = users; + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return users.size(); + } + + @Override + public Object getItem(int position) { + return users.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = inflater.inflate(R.layout.users_spinner_item, parent, false); + } + + User user = getUser(position); + ((TextView)view.findViewById(R.id.item)).setText(user.firstName + " " + user.lastName); + return view; + } + + User getUser(int position) { + return ((User) getItem(position)); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/AddNewsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/AddNewsFragment.java new file mode 100644 index 0000000..968396d --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/AddNewsFragment.java @@ -0,0 +1,127 @@ +package com.khsm.app.presentation.ui.screens.news; + +import android.app.ProgressDialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import com.khsm.app.R; +import com.khsm.app.data.entities.News; +import com.khsm.app.domain.NewsManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +@SuppressWarnings("FieldCanBeLocal") +public class AddNewsFragment extends Fragment { + public static AddNewsFragment newInstance() { + return new AddNewsFragment(); + } + + private Toolbar toolbar; + private EditText message_news_text; + private Button add_button; + + private NewsManager newsManager; + + @Nullable + private ProgressDialog progressDialog; + + @Nullable + private Disposable addNewsDisposable; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + newsManager = new NewsManager(requireContext()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.add_news_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(v -> { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + }); + + add_button = view.findViewById(R.id.add_button); + add_button.setOnClickListener(cm -> addNews()); + + message_news_text = view.findViewById(R.id.message_news_text); + + return view; + } + + public void addNews() { + if (message_news_text.length() < 1) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + News news = new News( null, null, message_news_text.getText().toString(), null); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + progressDialog = ProgressDialog.show(requireContext(), null, getString(R.string.Please_WaitD3), true, false); + + if (addNewsDisposable != null) { + addNewsDisposable.dispose(); + addNewsDisposable = null; + } + + addNewsDisposable = newsManager.addNews(news) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::addDone, + this::handleError + ); + } + + private void addDone(@SuppressWarnings("unused") News news) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + requireActivity().onBackPressed(); // FIXME: 30.03.2018 + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void handleError(@SuppressWarnings("unused") Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(R.string.AddNews_Error_NewsCreationError) + .setPositiveButton(R.string.OK, null) + .show(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsAdapter.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsAdapter.java new file mode 100644 index 0000000..0dba377 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsAdapter.java @@ -0,0 +1,107 @@ +package com.khsm.app.presentation.ui.screens.news; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import com.khsm.app.R; +import com.khsm.app.data.entities.News; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class NewsAdapter extends RecyclerView.Adapter { + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy HH:mm", Locale.getDefault()); + + private final Context context; + private LayoutInflater inflater; + private List news; + + NewsAdapter(Context context) { + this.context = context; + this.news = new ArrayList<>(); + + this.inflater = LayoutInflater.from(context); + } + + public void setNews(List news) { + this.news = news; + notifyDataSetChanged(); + } + + @NonNull + @Override + public NewsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.news_item, parent, false); + ViewHolder viewHolder = new ViewHolder(view); + viewHolder.newsText.setMovementMethod(LinkMovementMethod.getInstance()); + return viewHolder; + } + + @Override + public void onBindViewHolder(@NonNull NewsAdapter.ViewHolder holder, int position) { + News news = this.news.get(position); + holder.newsDate.setText(dateFormat.format(news.dateAndTime)); + holder.newsText.setText(prepareNewsText(news.text)); + holder.newsAuthor.setText(news.user.firstName + " " + news.user.lastName); + holder.news = news; + } + + private void makeLinkClickable(SpannableStringBuilder strBuilder, final URLSpan span) { + int start = strBuilder.getSpanStart(span); + int end = strBuilder.getSpanEnd(span); + int flags = strBuilder.getSpanFlags(span); + ClickableSpan clickable = new ClickableSpan() { + public void onClick(View view) { + // Do something with span.getURL() to handle the link click... + Toast.makeText(context, span.getURL(), Toast.LENGTH_LONG).show(); + } + }; + strBuilder.setSpan(clickable, start, end, flags); + strBuilder.removeSpan(span); + } + + private CharSequence prepareNewsText(String source) { + source = source.replace("\n", "
"); + + SpannableStringBuilder text = new SpannableStringBuilder(Html.fromHtml(source)); + URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class); + for (URLSpan span : spans) { + makeLinkClickable(text, span); + } + return text; + } + + @Override + public int getItemCount() { + return news.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + final TextView newsText; + final TextView newsDate; + final TextView newsAuthor; + + @Nullable + News news; + ViewHolder(View view){ + super(view); + newsText = view.findViewById(R.id.newsText); + newsDate = view.findViewById(R.id.newsDate); + newsAuthor = view.findViewById(R.id.newsAuthor); + } + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsFragment.java new file mode 100644 index 0000000..519334e --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/news/NewsFragment.java @@ -0,0 +1,137 @@ +package com.khsm.app.presentation.ui.screens.news; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.FloatingActionButton; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + + +import com.khsm.app.R; +import com.khsm.app.data.entities.News; +import com.khsm.app.data.entities.Session; +import com.khsm.app.domain.AuthManager; +import com.khsm.app.domain.NewsManager; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class NewsFragment extends Fragment { + + public static final String ROLE_ADMIN = "Admin"; + private AuthManager authManager; + + private NewsAdapter adapter; + private NewsManager newsManager; + private Toolbar toolbar; + private RecyclerView recyclerView; + private ProgressBar progressBar; + + private FloatingActionButton addNews_fab; + + @Nullable + private Disposable loadDisposable; + + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Context context = requireContext(); + + newsManager = new NewsManager(context); + adapter = new NewsAdapter(context); + authManager = new AuthManager(getContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.news_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + addNews_fab = view.findViewById(R.id.addNews_fab); + + if (authManager.isAdmin()) { + addNews_fab.setVisibility(View.VISIBLE); + } else { + addNews_fab.setVisibility(View.INVISIBLE); + } + + loadDisciplines(); + + View.OnClickListener addNews = new View.OnClickListener() { + @Override + public void onClick(View view) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.replaceFragment(AddNewsFragment.newInstance()); + } + }; + + addNews_fab.setOnClickListener(addNews); + return view; + } + + private void loadDisciplines() { + progressBar.setVisibility(View.VISIBLE); + + if (loadDisposable != null) { + loadDisposable.dispose(); + loadDisposable = null; + } + + loadDisposable = newsManager.getNews() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setNews, + this::handleError + ); + } + + private void setNews(List news) { + progressBar.setVisibility(View.INVISIBLE); + + adapter.setNews(news); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + public static Fragment newInstance() { + return new NewsFragment(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/EditProfileFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/EditProfileFragment.java new file mode 100644 index 0000000..1df6ce6 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/EditProfileFragment.java @@ -0,0 +1,277 @@ +package com.khsm.app.presentation.ui.screens.profile; + +import android.app.ProgressDialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.RadioButton; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; +import com.khsm.app.R; +import com.khsm.app.data.entities.Gender; +import com.khsm.app.data.entities.User; +import com.khsm.app.domain.UserManager; +import com.khsm.app.presentation.ui.screens.MainActivity; +import com.khsm.app.presentation.ui.utils.maskedittext.EditTextMask; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class EditProfileFragment extends Fragment implements Toolbar.OnMenuItemClickListener { + private static final SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy", Locale.getDefault()); + + public static EditProfileFragment newInstance() {return new EditProfileFragment();} + + private UserManager userManager; + + @SuppressWarnings("FieldCanBeLocal") + private Toolbar toolbar; + + private ImageView avatar_imageView; + private EditText firstName; + private EditText lastName; + private EditText wcaId; + private EditText city; + private EditText birthDate; + private EditText phoneNumber; + + private RadioButton male; + private RadioButton female; + + @SuppressWarnings("FieldCanBeLocal") + private Button save; + + @Nullable + private Disposable updateUserDisposable; + + @Nullable + private ProgressDialog progressDialog; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + userManager = new UserManager(requireContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.edit_profile_layout, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.inflateMenu(R.menu.edit_profile); + toolbar.setOnMenuItemClickListener(this); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + avatar_imageView = view.findViewById(R.id.avatar_imageView); + + firstName = view.findViewById(R.id.first_name); + lastName = view.findViewById(R.id.last_name); + wcaId = view.findViewById(R.id.wca_id); + city = view.findViewById(R.id.city); + birthDate = view.findViewById(R.id.birth_date); + EditTextMask.setup(birthDate, "##-##-####"); + phoneNumber = view.findViewById(R.id.phone_number); + EditTextMask.setup(phoneNumber, "#### (##) ###-##-##"); + + male = view.findViewById(R.id.male); + female = view.findViewById(R.id.female); + + save = view.findViewById(R.id.saveButton); + save.setOnClickListener(v -> updateUser()); + + return view; + } + + @Override + public void onStart() { + super.onStart(); + + User user = userManager.getUser(); + if (user != null) { + setUser(user); + } + } + + private void updateUser() { + // validation + if (firstName.length() < 1 + || lastName.length() < 1 + || (!male.isChecked() && !female.isChecked())) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + // init entities + Gender gender = + male.isChecked() ? Gender.MALE : + female.isChecked() ? Gender.FEMALE : + null; + + Date birthDate; + try { + birthDate = stringToJavaDate(this.birthDate.getText().toString()); + } catch (ParseException e) { + showErrorMessage(getString(R.string.Register_Error_CheckInputData)); + return; + } + + String phoneNumber = unmaskPhoneNumber(this.phoneNumber.getText().toString()); + + User user = new User( + firstName.getText().toString(), + lastName.getText().toString(), + gender, + city.getText().toString(), + wcaId.getText().toString(), + phoneNumber, + birthDate + ); + + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + progressDialog = ProgressDialog.show(requireContext(), null, getString(R.string.Please_WaitD3), true, false); + + if (updateUserDisposable != null) { + updateUserDisposable.dispose(); + updateUserDisposable = null; + } + + updateUserDisposable = userManager.updateUser(user) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::updateUserCompleted, + this::handleError + ); + } + + private void updateUserCompleted(User user){ + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + setUser(user); + + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.EditProfile_SuccessMessageE) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private Date stringToJavaDate(@NonNull String dateString) throws ParseException { + dateString = dateString.trim(); + + if (dateString.isEmpty()) + return null; + + return dateFormat.parse(dateString); + } + + private String unmaskPhoneNumber(String maskedPhoneNumber) { + return maskedPhoneNumber.trim() + .replace(" ", "") + .replace("(", "") + .replace(")", "") + .replace("-", ""); + } + + private void handleError(Throwable throwable) { + if (progressDialog != null) { + progressDialog.dismiss(); + progressDialog = null; + } + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void showErrorMessage(String errorMessage) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(errorMessage) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void setUser(User user) { + firstName.setText(user.firstName); + lastName.setText(user.lastName); + wcaId.setText(user.wcaId); + city.setText(user.city); + if (user.birthDate != null) + birthDate.setText(dateFormat.format(user.birthDate)); + phoneNumber.setText(user.phoneNumber); + + if (user.gender.equals(Gender.MALE)) + male.setChecked(true); + else if (user.gender.equals(Gender.FEMALE)) + female.setChecked(true); + + // TODO: 26.02.2018 fix this temporary implementation + if (user.gender == Gender.FEMALE) + { + Glide.with(this) + .load(R.drawable.avatar_female) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } else if (user.gender == Gender.MALE) { + Glide.with(this) + .load(R.drawable.avatar_male) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } else { + Glide.with(this) + .load(R.drawable.avatar_nobody) + .apply(RequestOptions.circleCropTransform()) + .into(avatar_imageView); + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + MainActivity activity = (MainActivity) requireActivity(); + + switch (item.getItemId()) { + case R.id.results: + activity.replaceFragment(MyResultsFragment.newInstance()); + return true; + case R.id.records: + activity.replaceFragment(MyRecordsFragment.newInstance()); + return true; + } + + return false; + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyRecordsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyRecordsFragment.java new file mode 100644 index 0000000..68aea97 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyRecordsFragment.java @@ -0,0 +1,130 @@ +package com.khsm.app.presentation.ui.screens.profile; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.entities.DisciplineRecord; +import com.khsm.app.domain.UserManager; +import com.khsm.app.presentation.ui.adapters.RecordsAdapter; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class MyRecordsFragment extends Fragment { + public static MyRecordsFragment newInstance() { + return new MyRecordsFragment(); + } + + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + + private ProgressBar progressBar; + + @Nullable + private Disposable loadDisposable; + + private UserManager userManager; + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private Toolbar toolbar; + + private RecordsAdapter adapter; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Context context = requireContext(); + + userManager = new UserManager(context); + adapter = new RecordsAdapter(context); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + // init view + View view = inflater.inflate(R.layout.my_records_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + // load data + loadRecords(); + + return view; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + cancelLoadOperation(); + } + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void loadRecords() { + progressBar.setVisibility(View.VISIBLE); + + cancelLoadOperation(); + loadDisposable = userManager.getMyRecords() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplineRecords, + this::handleError + ); + } + + private void setDisciplineRecords(List disciplineRecords) { + progressBar.setVisibility(View.INVISIBLE); + + adapter.setResults(disciplineRecords); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyResultsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyResultsFragment.java new file mode 100644 index 0000000..9b3c6f5 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/profile/MyResultsFragment.java @@ -0,0 +1,167 @@ +package com.khsm.app.presentation.ui.screens.profile; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.domain.UserManager; +import com.khsm.app.presentation.ui.adapters.AdapterUtils; +import com.khsm.app.presentation.ui.adapters.ResultsAdapter; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class MyResultsFragment extends Fragment { + public static MyResultsFragment newInstance() { + return new MyResultsFragment(); + } + + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + + private ProgressBar progressBar; + + @Nullable + private Disposable loadDisposable; + + private UserManager userManager; + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private Toolbar toolbar; + + private TabLayout tabLayout; + + private ResultsAdapter adapter; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Context context = requireContext(); + + userManager = new UserManager(context); + adapter = new ResultsAdapter(context, AdapterUtils.DisplayMode.Date); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + // init view + View view = inflater.inflate(R.layout.my_results_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + tabLayout = view.findViewById(R.id.tabLayout); + tabLayout.setVisibility(View.INVISIBLE); + tabLayout.addOnTabSelectedListener(onTabSelectedListener); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + // load data + loadResults(); + + return view; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + cancelLoadOperation(); + } + + private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + DisciplineResults disciplineResults = (DisciplineResults) tab.getTag(); + if (disciplineResults != null) { + setDisciplineResults(disciplineResults); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + } + }; + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void loadResults() { + progressBar.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.INVISIBLE); + + cancelLoadOperation(); + loadDisposable = userManager.getMyResults() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplineResults, + this::handleError + ); + } + + private void setDisciplineResults(List disciplineResults) { + progressBar.setVisibility(View.INVISIBLE); + + tabLayout.removeAllTabs(); + + for (DisciplineResults disciplineResult : disciplineResults) { + tabLayout.addTab(tabLayout.newTab().setText(disciplineResult.discipline.name).setTag(disciplineResult)); + } + + tabLayout.setVisibility(!disciplineResults.isEmpty() ? View.VISIBLE : View.INVISIBLE); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void setDisciplineResults(@NonNull DisciplineResults disciplineResults) { + adapter.setResults(disciplineResults.results, null); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/FilterDialogFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/FilterDialogFragment.java new file mode 100644 index 0000000..238444d --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/FilterDialogFragment.java @@ -0,0 +1,135 @@ +package com.khsm.app.presentation.ui.screens.rankings; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.RadioButton; + +import com.khsm.app.R; +import com.khsm.app.data.api.entities.RankingsFilterInfo; +import com.khsm.app.data.entities.Gender; + +public class FilterDialogFragment extends DialogFragment { + private static final String KEY_FILTER_INFO = "KEY_FILTER_INFO"; + + public static FilterDialogFragment newInstance(@NonNull RankingsFilterInfo filterInfo) { + FilterDialogFragment fragment = new FilterDialogFragment(); + + Bundle bundle = new Bundle(); + + bundle.putSerializable(KEY_FILTER_INFO, filterInfo); + + fragment.setArguments(bundle); + + return fragment; + } + + private RadioButton average; + private RadioButton single; + private RadioButton ascending; + private RadioButton descending; + private RadioButton male; + private RadioButton female; + private RadioButton both; + private Button cancel; + private Button apply; + + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + Bundle arguments = getArguments(); + if (arguments == null) + throw new RuntimeException("Arguments should not be null"); + + View view = inflater.inflate(R.layout.filter_dialog_fragment, null); + + average = view.findViewById(R.id.average); + single = view.findViewById(R.id.single); + ascending = view.findViewById(R.id.ascending); + descending = view.findViewById(R.id.descending); + male = view.findViewById(R.id.male); + female = view.findViewById(R.id.female); + both = view.findViewById(R.id.both); + cancel = view.findViewById(R.id.cancel); + apply = view.findViewById(R.id.apply); + + if (savedInstanceState == null) { + RankingsFilterInfo filterInfo = (RankingsFilterInfo) arguments.getSerializable(KEY_FILTER_INFO); + setFilterInfo(filterInfo); + } + + View.OnClickListener cancelClicked = new View.OnClickListener() { + @Override + public void onClick(View v) { + dismiss(); + } + }; + + View.OnClickListener applyClicked = new View.OnClickListener() { + @Override + public void onClick(View view) { + if ((!male.isChecked() && !female.isChecked() && !both.isChecked()) + || (!average.isChecked() && !single.isChecked()) + || (!ascending.isChecked() && !descending.isChecked())) { + return; + } + + Gender gender = male.isChecked() ? Gender.MALE : + female.isChecked() ? Gender.FEMALE : null; + RankingsFilterInfo.FilterType filterType = average.isChecked() ? RankingsFilterInfo.FilterType.Average : + single.isChecked() ? RankingsFilterInfo.FilterType.Single : null; + RankingsFilterInfo.SortType sortType = ascending.isChecked() ? RankingsFilterInfo.SortType.Ascending : + descending.isChecked() ? RankingsFilterInfo.SortType.Descending : null; + + RankingsFilterInfo rankingsFilterInfo = new RankingsFilterInfo(filterType, sortType, gender); + + RankingsFragment parentFragment = (RankingsFragment) getParentFragment(); + parentFragment.applyFilter(rankingsFilterInfo); + + dismiss(); + } + }; + + cancel.setOnClickListener(cancelClicked); + apply.setOnClickListener(applyClicked); + + return view; + } + + private void setFilterInfo(@NonNull RankingsFilterInfo filterInfo) { + switch (filterInfo.filterType) { + case Single: + single.setChecked(true); + break; + case Average: + average.setChecked(true); + break; + } + + if (filterInfo.gender != null) { + switch (filterInfo.gender) { + case MALE: + male.setChecked(true); + break; + case FEMALE: + female.setChecked(true); + break; + } + } else { + both.setChecked(true); + } + + switch (filterInfo.sortType) { + case Ascending: + ascending.setChecked(true); + break; + case Descending: + descending.setChecked(true); + break; + } + } +} + diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/RankingsFragment.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/RankingsFragment.java new file mode 100644 index 0000000..524bd49 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/screens/rankings/RankingsFragment.java @@ -0,0 +1,220 @@ +package com.khsm.app.presentation.ui.screens.rankings; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.khsm.app.R; +import com.khsm.app.data.api.entities.RankingsFilterInfo; +import com.khsm.app.data.entities.DisciplineResults; +import com.khsm.app.domain.RankingsManager; +import com.khsm.app.presentation.ui.adapters.AdapterUtils; +import com.khsm.app.presentation.ui.adapters.ResultsAdapter; +import com.khsm.app.presentation.ui.screens.MainActivity; + +import java.util.List; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class RankingsFragment extends Fragment implements Toolbar.OnMenuItemClickListener { + private static final String KEY_FILTER_INFO = "KEY_FILTER_INFO"; + + public static RankingsFragment newInstance() { + return new RankingsFragment(); + } + + @SuppressWarnings("FieldCanBeLocal") + private RecyclerView recyclerView; + + private ProgressBar progressBar; + + @Nullable + private Disposable loadDisposable; + + private RankingsManager rankingsManager; + + @SuppressWarnings({"FieldCanBeLocal", "unused"}) + private Toolbar toolbar; + + private TabLayout tabLayout; + + private ResultsAdapter adapter; + + private RankingsFilterInfo filterInfo; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Context context = requireContext(); + + rankingsManager = new RankingsManager(context); + adapter = new ResultsAdapter(context, AdapterUtils.DisplayMode.UserAndDate); + + if (savedInstanceState != null) { + filterInfo = (RankingsFilterInfo) savedInstanceState.getSerializable(KEY_FILTER_INFO); + } else { + filterInfo = new RankingsFilterInfo(RankingsFilterInfo.FilterType.Average, RankingsFilterInfo.SortType.Ascending, null); + } + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + Bundle savedInstanceState) { + // init view + View view = inflater.inflate(R.layout.rankings_fragment, container, false); + + toolbar = view.findViewById(R.id.toolbar); + toolbar.inflateMenu(R.menu.rankings); + toolbar.setOnMenuItemClickListener(this); + + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + MainActivity mainActivity = (MainActivity) requireActivity(); + mainActivity.showMenu(); + } + }); + + tabLayout = view.findViewById(R.id.tabLayout); + tabLayout.setVisibility(View.INVISIBLE); + tabLayout.addOnTabSelectedListener(onTabSelectedListener); + + recyclerView = view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + recyclerView.setAdapter(adapter); + + progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.INVISIBLE); + + // load data + loadRankings(); + + return view; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putSerializable(KEY_FILTER_INFO, filterInfo); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + cancelLoadOperation(); + } + + private final TabLayout.OnTabSelectedListener onTabSelectedListener = new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + DisciplineResults disciplineResults = (DisciplineResults) tab.getTag(); + if (disciplineResults != null) { + setDisciplineResults(disciplineResults); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + } + }; + + private void loadRankings() { + progressBar.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.INVISIBLE); + + cancelLoadOperation(); + loadDisposable = rankingsManager.getRankings(filterInfo) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::setDisciplineResults, + this::handleError + ); + } + + private void cancelLoadOperation() { + if (loadDisposable == null) + return; + + loadDisposable.dispose(); + loadDisposable = null; + } + + private void setDisciplineResults(List disciplineResults) { + progressBar.setVisibility(View.INVISIBLE); + + tabLayout.removeAllTabs(); + + for (DisciplineResults disciplineResult : disciplineResults) { + tabLayout.addTab(tabLayout.newTab().setText(disciplineResult.discipline.name).setTag(disciplineResult)); + } + + tabLayout.setVisibility(!disciplineResults.isEmpty() ? View.VISIBLE : View.INVISIBLE); + } + + private void handleError(Throwable throwable) { + progressBar.setVisibility(View.INVISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.Error) + .setMessage(throwable.getMessage()) + .setPositiveButton(R.string.OK, null) + .show(); + } + + private void setDisciplineResults(@NonNull DisciplineResults disciplineResults) { + AdapterUtils.SortMode sortMode; + if (filterInfo.filterType.equals(RankingsFilterInfo.FilterType.Average)) { + sortMode = AdapterUtils.SortMode.Average; + } else if (filterInfo.filterType.equals(RankingsFilterInfo.FilterType.Single)) { + sortMode = AdapterUtils.SortMode.Single; + } else { + throw new RuntimeException(); + } + + adapter.setResults(disciplineResults.results, sortMode); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.filter: + showFilterDialog(filterInfo); + return true; + } + + return false; + } + + private void showFilterDialog(RankingsFilterInfo filterInfo) { + FilterDialogFragment filterDialogFragment = FilterDialogFragment.newInstance(filterInfo); + filterDialogFragment.show(getChildFragmentManager(), null); + } + + public void applyFilter(RankingsFilterInfo filterInfo) { + this.filterInfo = filterInfo; + loadRankings(); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/PrintBitmapBuilder.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/PrintBitmapBuilder.java new file mode 100644 index 0000000..e339f81 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/PrintBitmapBuilder.java @@ -0,0 +1,120 @@ +package com.khsm.app.presentation.ui.utils; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Typeface; +import android.support.annotation.NonNull; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +public class PrintBitmapBuilder { + private static final int PRINT_WIDTH = 620; + private static final int PRINT_HIGHT = 877; + private static final int TEXT_SIZE = 15; + + private final Context context; + private final LinearLayout linearLayout; + + private int textSizeScale = 1; + + private ReceiptTextAlign textAlign = ReceiptTextAlign.LEFT; + + public PrintBitmapBuilder(@NonNull Context context) { + this.context = context; + + linearLayout = new LinearLayout(context); + linearLayout.setOrientation(LinearLayout.VERTICAL); + linearLayout.setBackgroundColor(Color.WHITE); + linearLayout.setPadding(20, 20, 20, 20); + } + + public void appendString(String text) { + TextView textView = new TextView(context); + + LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + textView.setLayoutParams(linearLayoutParams); + + int textAlignment = TextView.TEXT_ALIGNMENT_CENTER; + + if (textAlign != null) { + switch (textAlign) { + case LEFT: + textAlignment = TextView.TEXT_ALIGNMENT_TEXT_START; + break; + case CENTER: + textAlignment = TextView.TEXT_ALIGNMENT_CENTER; + break; + case RIGHT: + textAlignment = TextView.TEXT_ALIGNMENT_TEXT_END; + break; + } + } + + textView.setTextColor(Color.BLACK); + textView.setText(text); + textView.setBackgroundColor(Color.WHITE); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, TEXT_SIZE * textSizeScale); + textView.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL)); + textView.setTextAlignment(textAlignment); + + linearLayout.addView(textView); + } + + public void appendBitmap(Bitmap bitmap) { + ImageView imageView = new ImageView(context); + + LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + linearLayoutParams.gravity = Gravity.CENTER_HORIZONTAL; + + imageView.setImageBitmap(bitmap); + imageView.setLayoutParams(linearLayoutParams); + imageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + + linearLayout.addView(imageView); + } + + public void setTextAlign(ReceiptTextAlign textAlign) { + this.textAlign = textAlign; + } + + public void setTextSizeScale(int textSizeScale) { + this.textSizeScale = textSizeScale; + } + + public Bitmap build() { + int widthSpec = View.MeasureSpec.makeMeasureSpec (PRINT_WIDTH, View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec (PRINT_HIGHT, View.MeasureSpec.EXACTLY); + linearLayout.measure(widthSpec, heightSpec); + linearLayout.layout(0, 0, linearLayout.getMeasuredWidth(), linearLayout.getMeasuredHeight()); + + int width = linearLayout.getWidth(); + int height = linearLayout.getHeight(); + Bitmap bitmap = Bitmap.createBitmap( + width, height, + Bitmap.Config.RGB_565); + + Canvas canvas = new Canvas(bitmap); + + linearLayout.draw(canvas); + + return bitmap; + } + + public enum ReceiptTextAlign { + LEFT, + CENTER, + RIGHT; + + public static ReceiptTextAlign valueOf(int textAlign) { + return ReceiptTextAlign.values()[textAlign]; + } + } +} + diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/EditTextMask.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/EditTextMask.java new file mode 100644 index 0000000..7de2dd0 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/EditTextMask.java @@ -0,0 +1,10 @@ +package com.khsm.app.presentation.ui.utils.maskedittext; + +import android.widget.EditText; + +public class EditTextMask { + public static void setup(EditText editText, String mask) { + MaskEditTextChangedListener maskEditTextChangedListener = new MaskEditTextChangedListener(mask, editText); + editText.addTextChangedListener(maskEditTextChangedListener); + } +} diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/MaskEditTextChangedListener.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/MaskEditTextChangedListener.java new file mode 100644 index 0000000..1b2e9e3 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/MaskEditTextChangedListener.java @@ -0,0 +1,59 @@ +package com.khsm.app.presentation.ui.utils.maskedittext; + +import android.text.Editable; +import android.text.TextWatcher; +import android.widget.EditText; +import java.util.HashSet; +import java.util.Set; + +class MaskEditTextChangedListener implements TextWatcher { + private String mMask; + private EditText mEditText; + private Set symbolMask = new HashSet<>(); + private boolean isUpdating; + private String old = ""; + + MaskEditTextChangedListener(String mask, EditText editText) { + mMask = mask; + mEditText = editText; + initSymbolMask(); + } + + private void initSymbolMask() { + for (int i = 0; i < mMask.length(); i++) { + char ch = mMask.charAt(i); + if (ch != '#') + symbolMask.add(String.valueOf(ch)); + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + String str = Utils.unmask(s.toString(), symbolMask); + String mascara; + + if (isUpdating) { + old = str; + isUpdating = false; + return; + } + + if (str.length() > old.length()) + mascara = Utils.mask(mMask, str); + else + mascara = s.toString(); + + isUpdating = true; + + mEditText.setText(mascara); + mEditText.setSelection(mascara.length()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void afterTextChanged(Editable s) { + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/Utils.java b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/Utils.java new file mode 100644 index 0000000..fe115c7 --- /dev/null +++ b/Android/app/src/main/java/com/khsm/app/presentation/ui/utils/maskedittext/Utils.java @@ -0,0 +1,30 @@ +package com.khsm.app.presentation.ui.utils.maskedittext; + +import java.util.Set; + +abstract class Utils { + static String unmask(String s, Set replaceSymbols) { + for (String symbol : replaceSymbols) + s = s.replaceAll("[" + symbol + "]", ""); + + return s; + } + + static String mask(String format, String text) { + String maskedText = ""; + int i = 0; + for (char m : format.toCharArray()) { + if (m != '#') { + maskedText += m; + continue; + } + try { + maskedText += text.charAt(i); + } catch (Exception e) { + break; + } + i++; + } + return maskedText; + } +} \ No newline at end of file diff --git a/Android/app/src/main/res/anim/fade_in.xml b/Android/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..d4e222e --- /dev/null +++ b/Android/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/anim/fade_out.xml b/Android/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..e5f3e0d --- /dev/null +++ b/Android/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/drawable/avatar_female.jpg b/Android/app/src/main/res/drawable/avatar_female.jpg new file mode 100644 index 0000000..8aa7d41 Binary files /dev/null and b/Android/app/src/main/res/drawable/avatar_female.jpg differ diff --git a/Android/app/src/main/res/drawable/avatar_male.jpg b/Android/app/src/main/res/drawable/avatar_male.jpg new file mode 100644 index 0000000..8dc9a41 Binary files /dev/null and b/Android/app/src/main/res/drawable/avatar_male.jpg differ diff --git a/Android/app/src/main/res/drawable/avatar_nobody.jpg b/Android/app/src/main/res/drawable/avatar_nobody.jpg new file mode 100644 index 0000000..26427fb Binary files /dev/null and b/Android/app/src/main/res/drawable/avatar_nobody.jpg differ diff --git a/Android/app/src/main/res/drawable/ic_add_black_24dp.xml b/Android/app/src/main/res/drawable/ic_add_black_24dp.xml new file mode 100644 index 0000000..0258249 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_add_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/Android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 0000000..71d5bbd --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_arrow_upward_black_24dp.xml b/Android/app/src/main/res/drawable/ic_arrow_upward_black_24dp.xml new file mode 100644 index 0000000..d0a867b --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_arrow_upward_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_filter_list_white_24dp.xml b/Android/app/src/main/res/drawable/ic_filter_list_white_24dp.xml new file mode 100644 index 0000000..5d4ec18 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_filter_list_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_menu_white_24dp.xml b/Android/app/src/main/res/drawable/ic_menu_white_24dp.xml new file mode 100644 index 0000000..de103a6 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_menu_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_more_vert_black_24dp.xml b/Android/app/src/main/res/drawable/ic_more_vert_black_24dp.xml new file mode 100644 index 0000000..520a814 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_more_vert_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/Android/app/src/main/res/layout/activity_main.xml b/Android/app/src/main/res/layout/activity_main.xml index cf73786..79bc1a0 100644 --- a/Android/app/src/main/res/layout/activity_main.xml +++ b/Android/app/src/main/res/layout/activity_main.xml @@ -1,16 +1,25 @@ - + android:fitsSystemWindows="true"> - - + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/add_meeting_fragment.xml b/Android/app/src/main/res/layout/add_meeting_fragment.xml new file mode 100644 index 0000000..79c431b --- /dev/null +++ b/Android/app/src/main/res/layout/add_meeting_fragment.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/add_news_fragment.xml b/Android/app/src/main/res/layout/add_news_fragment.xml new file mode 100644 index 0000000..349459c --- /dev/null +++ b/Android/app/src/main/res/layout/add_news_fragment.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/add_results_fragment.xml b/Android/app/src/main/res/layout/add_results_fragment.xml new file mode 100644 index 0000000..a0082af --- /dev/null +++ b/Android/app/src/main/res/layout/add_results_fragment.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/discipline_details_fragment.xml b/Android/app/src/main/res/layout/discipline_details_fragment.xml new file mode 100644 index 0000000..7e470f2 --- /dev/null +++ b/Android/app/src/main/res/layout/discipline_details_fragment.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/disciplines_list_fragment.xml b/Android/app/src/main/res/layout/disciplines_list_fragment.xml new file mode 100644 index 0000000..dbb1884 --- /dev/null +++ b/Android/app/src/main/res/layout/disciplines_list_fragment.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/disciplines_list_item.xml b/Android/app/src/main/res/layout/disciplines_list_item.xml new file mode 100644 index 0000000..c2d471c --- /dev/null +++ b/Android/app/src/main/res/layout/disciplines_list_item.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/disciplines_spinner_item.xml b/Android/app/src/main/res/layout/disciplines_spinner_item.xml new file mode 100644 index 0000000..045324e --- /dev/null +++ b/Android/app/src/main/res/layout/disciplines_spinner_item.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/edit_profile_layout.xml b/Android/app/src/main/res/layout/edit_profile_layout.xml new file mode 100644 index 0000000..5562b42 --- /dev/null +++ b/Android/app/src/main/res/layout/edit_profile_layout.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/filter_dialog_fragment.xml b/Android/app/src/main/res/layout/filter_dialog_fragment.xml new file mode 100644 index 0000000..36b0686 --- /dev/null +++ b/Android/app/src/main/res/layout/filter_dialog_fragment.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/login_activity.xml b/Android/app/src/main/res/layout/login_activity.xml new file mode 100644 index 0000000..47b99ec --- /dev/null +++ b/Android/app/src/main/res/layout/login_activity.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/meeting_results_fragment.xml b/Android/app/src/main/res/layout/meeting_results_fragment.xml new file mode 100644 index 0000000..399761c --- /dev/null +++ b/Android/app/src/main/res/layout/meeting_results_fragment.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/meetings_list_fragment.xml b/Android/app/src/main/res/layout/meetings_list_fragment.xml new file mode 100644 index 0000000..ae2d86c --- /dev/null +++ b/Android/app/src/main/res/layout/meetings_list_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/meetings_list_item.xml b/Android/app/src/main/res/layout/meetings_list_item.xml new file mode 100644 index 0000000..846c523 --- /dev/null +++ b/Android/app/src/main/res/layout/meetings_list_item.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/my_records_fragment.xml b/Android/app/src/main/res/layout/my_records_fragment.xml new file mode 100644 index 0000000..926bdd7 --- /dev/null +++ b/Android/app/src/main/res/layout/my_records_fragment.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/my_results_fragment.xml b/Android/app/src/main/res/layout/my_results_fragment.xml new file mode 100644 index 0000000..f79feb9 --- /dev/null +++ b/Android/app/src/main/res/layout/my_results_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/nav_header.xml b/Android/app/src/main/res/layout/nav_header.xml new file mode 100644 index 0000000..538d2a1 --- /dev/null +++ b/Android/app/src/main/res/layout/nav_header.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/news_fragment.xml b/Android/app/src/main/res/layout/news_fragment.xml new file mode 100644 index 0000000..309d20a --- /dev/null +++ b/Android/app/src/main/res/layout/news_fragment.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/news_item.xml b/Android/app/src/main/res/layout/news_item.xml new file mode 100644 index 0000000..5c1c9f0 --- /dev/null +++ b/Android/app/src/main/res/layout/news_item.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/rankings_fragment.xml b/Android/app/src/main/res/layout/rankings_fragment.xml new file mode 100644 index 0000000..61d3d36 --- /dev/null +++ b/Android/app/src/main/res/layout/rankings_fragment.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/records_item.xml b/Android/app/src/main/res/layout/records_item.xml new file mode 100644 index 0000000..c4dbe6c --- /dev/null +++ b/Android/app/src/main/res/layout/records_item.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/register_activity.xml b/Android/app/src/main/res/layout/register_activity.xml new file mode 100644 index 0000000..4c8960b --- /dev/null +++ b/Android/app/src/main/res/layout/register_activity.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/layout/list_item.xml b/Android/app/src/main/res/layout/results_item.xml similarity index 59% rename from Android/app/src/main/res/layout/list_item.xml rename to Android/app/src/main/res/layout/results_item.xml index 0c26f96..512eeb5 100644 --- a/Android/app/src/main/res/layout/list_item.xml +++ b/Android/app/src/main/res/layout/results_item.xml @@ -3,18 +3,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="8dp"> + android:padding="16dp"> + android:layout_height="wrap_content" /> + android:layout_height="wrap_content" /> \ No newline at end of file diff --git a/Android/app/src/main/res/layout/users_spinner_item.xml b/Android/app/src/main/res/layout/users_spinner_item.xml new file mode 100644 index 0000000..045324e --- /dev/null +++ b/Android/app/src/main/res/layout/users_spinner_item.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Android/app/src/main/res/menu/drawer_view.xml b/Android/app/src/main/res/menu/drawer_view.xml new file mode 100644 index 0000000..6cd5451 --- /dev/null +++ b/Android/app/src/main/res/menu/drawer_view.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/menu/edit_profile.xml b/Android/app/src/main/res/menu/edit_profile.xml new file mode 100644 index 0000000..3dbfa14 --- /dev/null +++ b/Android/app/src/main/res/menu/edit_profile.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/menu/rankings.xml b/Android/app/src/main/res/menu/rankings.xml new file mode 100644 index 0000000..0750b62 --- /dev/null +++ b/Android/app/src/main/res/menu/rankings.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/values-ru/strings_generated.xml b/Android/app/src/main/res/values-ru/strings_generated.xml index 348d485..21bc5df 100644 --- a/Android/app/src/main/res/values-ru/strings_generated.xml +++ b/Android/app/src/main/res/values-ru/strings_generated.xml @@ -1,6 +1,72 @@  + Применить + По-возрастанию + Среднее + Лучший + Оба + Отмена + Город + Повторить пароль + По-убыванию + детали + Дисциплины + DNF + DNS + Изменить профиль + Email Ошибка + Женский + Имя + Последняя встреча + Фамилия + Вход + Выход + Мужской + Встречи + Мой профиль + Мои рекорды + Мои результаты + Новости OK + Пароль + Пожалуйста, подождите… + Рейтинг + Рекорды + Регистрация + Результаты + Сохранить + Единичный + WCA ID + Добавить встречу + Номер встречи + Дата встречи + Готово + Добавить результаты + Участники + Добавить + Добавить новость + Дата новости + Новое сообщение + Введите результаты(один результат на каждой строке) + Печать + Введите текст + + ФИЛЬТР + Фильтр + + Проверьте введенные данные + Произошла ошибка при аутентификации + Произошла ошибка при регистрации + У вас нет учетной записи + Проверьте введенные данные + Создать учетную запись + Произошла ошибка при добавлении встречи + Произошла ошибка при добавлении новости + Произошла ошибка при добавлении результата + + Дата рождения (ДД-ММ-ГГГГ) + Номер телефона (+380 XX XXX-XX-XX) + Информация обновлена успешно! 😀 \ No newline at end of file diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml index 3ab3e9c..9f3c494 100644 --- a/Android/app/src/main/res/values/colors.xml +++ b/Android/app/src/main/res/values/colors.xml @@ -1,6 +1,7 @@ - #3F51B5 - #303F9F - #FF4081 + #3949ab + #1a237e + #00b0ff + #ffffff diff --git a/Android/app/src/main/res/values/strings_generated.xml b/Android/app/src/main/res/values/strings_generated.xml index 55abd0b..da75ba5 100644 --- a/Android/app/src/main/res/values/strings_generated.xml +++ b/Android/app/src/main/res/values/strings_generated.xml @@ -1,6 +1,72 @@  + Apply + Ascending + Average + Best + Both + Cancel + City + Confirm Password + Descending + details + Disciplines + DNF + DNS + Edit Profile + Email Error + Female + First Name + Last Meeting + Last Name + Login + Logout + Male + Meetings + My Profile + My Records + My Results + News OK + Password + Please, wait… + Rankings + Records + Register + Results + Save + Single + WCA ID + Add Meeting + Meeting Number + Meeting Date + Done + Add Results + Users + Add + Add News + Add News Date + Мessage News Text + Write results(one on each row) + Print + Write the text + + SORT & FILTER + Sort & Filter + + Check the entered data + User authentication error + User register error + You doesn\'t have an account + Сheck the entered data + Create an account + Meeting creation error + News creation error + Result creation error + + Birth Date (DD-MM-YYYY) + Phone Number (+380 XX XXX-XX-XX) + User information was updated successfully! 😀 \ No newline at end of file diff --git a/Android/app/src/main/res/values/styles.xml b/Android/app/src/main/res/values/styles.xml index 5885930..b3e345e 100644 --- a/Android/app/src/main/res/values/styles.xml +++ b/Android/app/src/main/res/values/styles.xml @@ -1,11 +1,18 @@ - + + diff --git a/Android/build.gradle b/Android/build.gradle index f998407..9cc5225 100644 --- a/Android/build.gradle +++ b/Android/build.gradle @@ -7,7 +7,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.2.0-alpha03' + classpath 'com.android.tools.build:gradle:3.2.0-alpha14' } } @@ -24,37 +24,46 @@ task clean(type: Delete) { task updateLocalizationResources { Properties properties = new Properties() - properties.load(project.rootProject.file('local.properties').newDataInputStream()) - - def projectPathProperty = 'com.SpryRocks.AppLocalizationUtil.ProjectPath' - def configFileProperty = 'com.SpryRocks.AppLocalizationUtil.ConfigFile' - - def projectPath = properties.getProperty(projectPathProperty) - if (projectPath != null) { - exec { - executable "dotnet" - args 'run', - '--project', projectPath, - '--ConfigFile', project.property(configFileProperty) + + def localPropertiesFile = project.rootProject.file('local.properties') + + if (localPropertiesFile.exists()) { + properties.load(localPropertiesFile.newDataInputStream()) + + def projectPathProperty = 'com.SpryRocks.AppLocalizationUtil.ProjectPath' + def configFileProperty = 'com.SpryRocks.AppLocalizationUtil.ConfigFile' + + def projectPath = properties.getProperty(projectPathProperty) + if (projectPath != null) { + exec { + executable "dotnet" + args 'run', + '--project', projectPath, + '--ConfigFile', project.property(configFileProperty) + } } } } project.ext { compileSdkVersion = 27 - minSdkVersion = 16 + minSdkVersion = 17 targetSdkVersion = 27 versionCode = 1 versionName = "1.0" - ANDROID_SUPPORT_VERSION = "27.0.2" - CONSTRAINT_LAYOUT_VERSION = "1.0.2" + ANDROID_SUPPORT_VERSION = "27.1.1" + CONSTRAINT_LAYOUT_VERSION = "1.1.0" - RX_ANDROID_VERSION = "2.0.1" - RX_JAVA_VERSION = "2.1.8" + RX_ANDROID_VERSION = "2.0.2" + RX_JAVA_VERSION = "2.1.9" RETROFIT_VERSION = "2.3.0" RETROFIT_ADAPTER_RXJAVA2 = RETROFIT_VERSION OKHTTP3_VERSION = "3.9.1" + + GLIDE_VERSION = "4.6.1" + + RX_PREFERENCES_VERSION = "2.0.0-RC3" } diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties index 4f4d58e..443e7c4 100644 --- a/Android/gradle/wrapper/gradle-wrapper.properties +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Feb 13 17:45:24 EET 2018 +#Tue Apr 03 15:25:59 EEST 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip diff --git a/Backend/Backend.csproj b/Backend/Backend.csproj index 2cac534..2192289 100644 --- a/Backend/Backend.csproj +++ b/Backend/Backend.csproj @@ -1,13 +1,15 @@  netcoreapp2.0 + 7.2 - - + + + diff --git a/Backend/Backend.sln b/Backend/Backend.sln index 1806115..ab3cf77 100644 --- a/Backend/Backend.sln +++ b/Backend/Backend.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend", "Backend.csproj", "{87BF6F14-61AB-4127-B9A4-D147B1E60EE6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProject", "..\Test\Backend\TestProject.csproj", "{CCD57155-A4C1-45E5-A3F1-E6E1F187CCAB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {87BF6F14-61AB-4127-B9A4-D147B1E60EE6}.Debug|Any CPU.Build.0 = Debug|Any CPU {87BF6F14-61AB-4127-B9A4-D147B1E60EE6}.Release|Any CPU.ActiveCfg = Release|Any CPU {87BF6F14-61AB-4127-B9A4-D147B1E60EE6}.Release|Any CPU.Build.0 = Release|Any CPU + {CCD57155-A4C1-45E5-A3F1-E6E1F187CCAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCD57155-A4C1-45E5-A3F1-E6E1F187CCAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCD57155-A4C1-45E5-A3F1-E6E1F187CCAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCD57155-A4C1-45E5-A3F1-E6E1F187CCAB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Backend/Controllers/ApiController.cs b/Backend/Controllers/ApiController.cs index 9316702..664d2cf 100644 --- a/Backend/Controllers/ApiController.cs +++ b/Backend/Controllers/ApiController.cs @@ -1,9 +1,73 @@ -using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq; +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; namespace Backend.Controllers { [Route("api/[controller]")] + [Produces("application/json")] public abstract class ApiController : Controller { + private readonly UsersManager _usersManager; + + // ReSharper disable once MemberCanBePrivate.Global + public Session Session { get; private set; } + // ReSharper disable once UnusedMember.Global + public new User User => Session?.User; + + protected ApiController(UsersManager usersManager) + { + _usersManager = usersManager; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + base.OnActionExecuting(context); + + var authorization = Request.Headers["Authorization"].SingleOrDefault(); + + Authenticate(authorization); + } + + protected bool IsMe(int userId) + { + return User?.Id == userId; + } + + protected bool IsAuthenticated() + { + // ReSharper disable once UnusedVariable + return IsAuthenticated(out var user); + } + + // ReSharper disable once MemberCanBePrivate.Global + protected bool IsAuthenticated(out User user) + { + user = User; + return user != null; + } + + // ReSharper disable once MemberCanBePrivate.Global + protected bool IsAdmin(out User user) + { + return IsAuthenticated(out user) && user.IsAdmin(); + } + + // ReSharper disable once UnusedMember.Global + protected bool IsAdmin() + { + // ReSharper disable once UnusedVariable + return IsAdmin(out var user); + } + + private void Authenticate(string sessionToken) + { + if (sessionToken == null) return; + + Session = _usersManager.FindSession(sessionToken) ?? throw new Exception("Authorization failed"); + } } } \ No newline at end of file diff --git a/Backend/Controllers/DisciplinesController.cs b/Backend/Controllers/DisciplinesController.cs new file mode 100644 index 0000000..784f979 --- /dev/null +++ b/Backend/Controllers/DisciplinesController.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers +{ + public class DisciplinesController : ApiController + { + private readonly DisciplinesManager _disciplinesManager; + + public DisciplinesController(DisciplinesManager disciplinesManager, UsersManager usersManager) : base(usersManager) + { + _disciplinesManager = disciplinesManager; + } + + [HttpGet] + public IEnumerable Get() + { + return _disciplinesManager.GetDisciplinesAsync(); + } + } +} \ No newline at end of file diff --git a/Backend/Controllers/MeetingsController.cs b/Backend/Controllers/MeetingsController.cs new file mode 100644 index 0000000..8c73081 --- /dev/null +++ b/Backend/Controllers/MeetingsController.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Net; +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers +{ + public class MeetingsController : ApiController + { + private readonly MeetingsManager _meetingsManager; + private readonly ResultsManager _resultsManager; + + public MeetingsController(MeetingsManager meetingsManager, ResultsManager resultsManager, UsersManager usersManager) : base(usersManager) + { + _meetingsManager = meetingsManager; + _resultsManager = resultsManager; + } + + [HttpGet] + [ProducesResponseType(typeof(List), (int)HttpStatusCode.OK)] + public IEnumerable Get() + { + return _meetingsManager.GetMeetings(); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(Meeting), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult Get(int id) + { + var meeting = _meetingsManager.GetMeeting(id); + if (meeting == null) + return NotFound(); + + return Json(meeting); + } + + [HttpGet("last")] + [ProducesResponseType(typeof(Meeting), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult GetLast() + { + var meeting = _meetingsManager.GetLastMeeting(); + if (meeting == null) + return NotFound(); + + return Json(meeting); + } + + [HttpGet("{id}/results")] + [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult GetResults(int id) + { + var results = _resultsManager.GetMeetingResults(id); + if (results == null) + return NotFound(); + + return Json(results); + } + + [HttpPost] + public IActionResult CreateMeeting([FromBody] Meeting meeting) + { + if (!IsAdmin()) + return Unauthorized(); + + _meetingsManager.AddMeeting(meeting); + + return Json(meeting); + } + + [HttpPost("results")] + public IActionResult CreateResult([FromBody] Result result) + { + if (!IsAdmin()) + return Unauthorized(); + + _resultsManager.AddResult(result); + + return Json(result); + } + } +} \ No newline at end of file diff --git a/Backend/Controllers/NewsController.cs b/Backend/Controllers/NewsController.cs new file mode 100644 index 0000000..da49903 --- /dev/null +++ b/Backend/Controllers/NewsController.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Net; +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers +{ + public class NewsController : ApiController + { + private readonly NewsManager _newsManager; + + public NewsController(NewsManager newsManager, UsersManager usersManager) : base(usersManager) + { + _newsManager = newsManager; + } + + [HttpGet] + public IEnumerable Get() + { + return _newsManager.GetNewsAsyns(); + } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(News), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult Get(int id) + { + var meeting = _newsManager.GetNewsAsyns(); + if (meeting == null) + return NotFound(); + + return Json(meeting); + } + + [HttpGet("last")] + [ProducesResponseType(typeof(News), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult GetLast() + { + var meeting = _newsManager.GetNewsAsyns(); + if (meeting == null) + return NotFound(); + + return Json(meeting); + } + + [HttpPost] + public IActionResult AddNews([FromBody] News news) + { + if (!IsAdmin(out var user)) + return Unauthorized(); + + news.User = user; + + _newsManager.AddNews(news); + + return Json(news); + } + } +} \ No newline at end of file diff --git a/Backend/Controllers/RankingsController.cs b/Backend/Controllers/RankingsController.cs new file mode 100644 index 0000000..09693e8 --- /dev/null +++ b/Backend/Controllers/RankingsController.cs @@ -0,0 +1,26 @@ +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers +{ + public class RankingsController : ApiController + { + private readonly ResultsManager _resultsManager; + + public RankingsController(UsersManager usersManager, ResultsManager resultsManager) : base(usersManager) + { + _resultsManager = resultsManager; + } + + [HttpGet] + public IActionResult GetRankings([FromQuery] ResultsManager.FilterType type, [FromQuery] ResultsManager.SortType sort, [FromQuery] Gender? gender) + { + var results = _resultsManager.GetRankings(type, sort, gender); + if (results == null) + return NotFound(); + + return Json(results); + } + } +} \ No newline at end of file diff --git a/Backend/Controllers/SessionsController.cs b/Backend/Controllers/SessionsController.cs new file mode 100644 index 0000000..fef64c1 --- /dev/null +++ b/Backend/Controllers/SessionsController.cs @@ -0,0 +1,22 @@ +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers +{ + public class SessionsController : ApiController + { + private readonly UsersManager _usersManager; + + public SessionsController(UsersManager usersManager) : base(usersManager) + { + _usersManager = usersManager; + } + + [HttpPost] + public Session Login([FromBody] CreateSessionRequest createSessionRequest) + { + return _usersManager.Login(createSessionRequest); + } + } +} \ No newline at end of file diff --git a/Backend/Controllers/UsersController.cs b/Backend/Controllers/UsersController.cs index f79c747..01d6a60 100644 --- a/Backend/Controllers/UsersController.cs +++ b/Backend/Controllers/UsersController.cs @@ -1,26 +1,196 @@ -using System.Collections.Generic; -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; using Backend.Data.Entities; using Backend.Domain; using Microsoft.AspNetCore.Mvc; namespace Backend.Controllers { - [Route("api/[controller]")] public class UsersController : ApiController { private readonly UsersManager _usersManager; + private readonly ResultsManager _resultsManager; - public UsersController(UsersManager usersManager) + public UsersController(UsersManager usersManager, ResultsManager resultsManager) : base(usersManager) { _usersManager = usersManager; + _resultsManager = resultsManager; + } + + [HttpPost] + public Session Register([FromBody] CreateUserRequest createUserRequest) + { + return _usersManager.Register(createUserRequest); } + [HttpGet("{id}")] + [ProducesResponseType(typeof(User), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public IActionResult GetUser(int id) + { + var readPrivateFields = id == User?.Id; + + var user = _usersManager.GetUser(id, readPrivateFields); + if (user == null) + return NotFound(); + + return Json(user); + } + + [HttpGet("me")] + [ProducesResponseType(typeof(User), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public IActionResult GetUser() + { + if (!IsAuthenticated(out var user)) + return Unauthorized(); + + return Json(user); + } + [HttpGet] - public async Task> Get() + public IActionResult GetUsers() + { + if (!IsAdmin()) + return Unauthorized(); + + return Json(_usersManager.GetUsers(false)); + } + + [HttpPut("{id}")] + [ProducesResponseType(typeof(User), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult UpdateUser(int id, [FromBody] User user) + { + if (!IsMe(id)) + return Unauthorized(); + + if (user.Id.HasValue && user.Id.Value != id) + throw new Exception("Not consistent user id provided"); + + user.Id = id; + + return Json(_usersManager.UpdateUser(user)); + } + + [HttpPut("me")] + [ProducesResponseType(typeof(User), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult UpdateUser([FromBody] User user) + { + if (!IsAuthenticated(out var me)) + return Unauthorized(); + + Debug.Assert(me.Id != null, "me.Id != null"); + var id = me.Id.Value; + + if (user.Id.HasValue && user.Id.Value != id) + return Unauthorized(); + + user.Id = id; + + return Json(_usersManager.UpdateUser(user)); + } + + [HttpPut("{id}/password")] + [ProducesResponseType((int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult UpdatePassword(int id, [FromBody] UpdatePasswordRequest updatePasswordRequest) + { + if (!IsAuthenticated(out var me)) + return Unauthorized(); + + Debug.Assert(me.Id != null, "me.Id != null"); + if (me.Id.Value != id) + return Unauthorized(); + + _usersManager.UpdatePassword(id, updatePasswordRequest.Password); + + return Ok(); + } + + [HttpPut("me/password")] + [ProducesResponseType((int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult UpdatePassword([FromBody] UpdatePasswordRequest updatePasswordRequest) + { + if (!IsAuthenticated(out var me)) + return Unauthorized(); + + Debug.Assert(me.Id != null, "me.Id != null"); + var id = me.Id.Value; + + _usersManager.UpdatePassword(id, updatePasswordRequest.Password); + + return Ok(); + } + + [HttpGet("{id}/results")] + [ProducesResponseType(typeof(IEnumerable), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult GetUserResults(int id) + { + if (!IsMe(id)) + return Unauthorized(); + + var results = _resultsManager.GetUserResults(id); + if (results == null) + return NotFound(); + + return Json(results); + } + + [HttpGet("me/results")] + [ProducesResponseType(typeof(IEnumerable), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult GetUserResults() { - var users = await _usersManager.GetUsersAsync(); - return users; + if (!IsAuthenticated(out var me)) + return Unauthorized(); + + Debug.Assert(me.Id != null, "me.Id != null"); + var id = me.Id.Value; + + var results = _resultsManager.GetUserResults(id); + if (results == null) + return NotFound(); + + return Json(results); + } + + [HttpGet("{id}/records")] + [ProducesResponseType(typeof(IEnumerable), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult GetUserRecords(int id) + { + if (!IsMe(id)) + return Unauthorized(); + + var records = _resultsManager.GetUserRecords(id); + if (records == null) + return NotFound(); + + return Json(records); + } + + [HttpGet("me/records")] + [ProducesResponseType(typeof(IEnumerable), (int) HttpStatusCode.OK)] + [ProducesResponseType((int) HttpStatusCode.Unauthorized)] + public IActionResult GetUserRecords() + { + if (!IsAuthenticated(out var me)) + return Unauthorized(); + + Debug.Assert(me.Id != null, "me.Id != null"); + var id = me.Id.Value; + + var records = _resultsManager.GetUserRecords(id); + if (records == null) + return NotFound(); + + return Json(records); } } } \ No newline at end of file diff --git a/Backend/Data/Database/DatabaseContext.cs b/Backend/Data/Database/DatabaseContext.cs index e268317..aecf855 100644 --- a/Backend/Data/Database/DatabaseContext.cs +++ b/Backend/Data/Database/DatabaseContext.cs @@ -1,9 +1,9 @@ using System; -using Microsoft.AspNetCore.Mvc; using MySql.Data.MySqlClient; namespace Backend.Data.Database { + // ReSharper disable once ClassNeverInstantiated.Global public class DatabaseContext : IDisposable { public MySqlConnection Connection { get; } @@ -19,6 +19,47 @@ public void Dispose() Connection.Dispose(); } + public void UseTransaction(Action action) + { + MySqlTransaction transaction = null; + try + { + transaction = Connection.BeginTransaction(); + action(transaction); + transaction.Commit(); + } + catch + { + transaction?.Rollback(); + throw; + } + finally + { + transaction?.Dispose(); + } + } + + public T UseTransaction(Func action) + { + MySqlTransaction transaction = null; + try + { + transaction = Connection.BeginTransaction(); + var value = action(transaction); + transaction.Commit(); + return value; + } + catch + { + transaction?.Rollback(); + throw; + } + finally + { + transaction?.Dispose(); + } + } + private static MySqlConnection CreateConnection() { return new MySqlConnection(ConectionString); @@ -33,6 +74,5 @@ private static MySqlConnection CreateConnection() Password = "sKiLlet", Database = "kh_sm" }.ToString(); - } } \ No newline at end of file diff --git a/Backend/Data/Database/Entities/Attempt.cs b/Backend/Data/Database/Entities/Attempt.cs new file mode 100644 index 0000000..b0216c6 --- /dev/null +++ b/Backend/Data/Database/Entities/Attempt.cs @@ -0,0 +1,11 @@ +using Backend.Data.Entities; + +namespace Backend.Data.Database.Entities +{ + public class Attempt + { + public int Id { get; set; } + public Result Result { get; set; } + public decimal? Time { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Database/Entities/Login.cs b/Backend/Data/Database/Entities/Login.cs new file mode 100644 index 0000000..76c43c8 --- /dev/null +++ b/Backend/Data/Database/Entities/Login.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using Backend.Data.Entities; + +namespace Backend.Data.Database.Entities +{ + public class Login + { + [Required] + public int Id { get; set; } + [Required] + public byte[] Hash { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/CreateSessionRequest.cs b/Backend/Data/Entities/CreateSessionRequest.cs new file mode 100644 index 0000000..5871aad --- /dev/null +++ b/Backend/Data/Entities/CreateSessionRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class CreateSessionRequest + { + [Required] + public string Email { get; set; } + [Required] + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/CreateUserRequest.cs b/Backend/Data/Entities/CreateUserRequest.cs new file mode 100644 index 0000000..e3f0aa0 --- /dev/null +++ b/Backend/Data/Entities/CreateUserRequest.cs @@ -0,0 +1,13 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class CreateUserRequest + { + [Required] + public User User { get; set; } + [Required] + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/Discipline.cs b/Backend/Data/Entities/Discipline.cs new file mode 100644 index 0000000..d357921 --- /dev/null +++ b/Backend/Data/Entities/Discipline.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class Discipline + { + [Required] + public int Id { get; set; } + [Required] + public string Name { get; set; } + public string Description { get; set; } + public string Counting { get; set; } + + protected bool Equals(Discipline other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((Discipline) obj); + } + + public override int GetHashCode() + { + return Id; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/DisciplineRecord.cs b/Backend/Data/Entities/DisciplineRecord.cs new file mode 100644 index 0000000..e53c2ac --- /dev/null +++ b/Backend/Data/Entities/DisciplineRecord.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class DisciplineRecord + { + [Required] + public Discipline Discipline { get; set; } + [Required] + public Result BestSingleResult { get; set; } + [Required] + public Result BestAverageResult { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/DisciplineResults.cs b/Backend/Data/Entities/DisciplineResults.cs new file mode 100644 index 0000000..0c29a7a --- /dev/null +++ b/Backend/Data/Entities/DisciplineResults.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class DisciplineResults + { + [Required] + public Discipline Discipline { get; set; } + [Required] + public IEnumerable Results { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/Gender.cs b/Backend/Data/Entities/Gender.cs new file mode 100644 index 0000000..227af7d --- /dev/null +++ b/Backend/Data/Entities/Gender.cs @@ -0,0 +1,7 @@ +namespace Backend.Data.Entities +{ + public enum Gender + { + Male, Female + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/Meeting.cs b/Backend/Data/Entities/Meeting.cs new file mode 100644 index 0000000..eeb2442 --- /dev/null +++ b/Backend/Data/Entities/Meeting.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class Meeting + { + [Required] + public int Id { get; set; } + [Required] + public int Number { get; set; } + [Required] + public DateTime Date { get; set; } + + protected bool Equals(Meeting other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((Meeting) obj); + } + + public override int GetHashCode() + { + return Id; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/News.cs b/Backend/Data/Entities/News.cs new file mode 100644 index 0000000..fff3d75 --- /dev/null +++ b/Backend/Data/Entities/News.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class News + { + [Required] + public int Id { get; set; } + [Required] + public User User { get; set; } + [Required] + public String Text { get; set; } + [Required] + public DateTime? DateAndTime { get; set; } + + protected bool Equals(News other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((News) obj); + } + + public override int GetHashCode() + { + return Id; + } + + } + +} + + + \ No newline at end of file diff --git a/Backend/Data/Entities/Result.cs b/Backend/Data/Entities/Result.cs new file mode 100644 index 0000000..8cdd7af --- /dev/null +++ b/Backend/Data/Entities/Result.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Linq; + +namespace Backend.Data.Entities +{ + public class Result + { + [Required] + public int Id { get; set; } + public Meeting Meeting { get; set; } + public Discipline Discipline { get; set; } + public User User { get; set; } + public decimal? Average { get; set; } + [Required] + public IEnumerable Attempts { get; set; } + public int AttemptCount { get; set; } + + protected bool Equals(Result other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((Result) obj); + } + + public override int GetHashCode() + { + return Id; + } + + public class Comparer : IComparer + { + private readonly Mode _mode; + + public Comparer(Mode mode) + { + _mode = mode; + } + + public int Compare(Result x, Result y) + { + switch (_mode) + { + case Mode.Average: + { + var c = CompareByAverage(x, y); + if (c == 0) + c = ComareByAttempts(x, y); + return c; + } + case Mode.Single: + { + var c = ComareByAttempts(x, y); + return c; + } + default: + throw new Exception("Not supported mode"); + } + } + + private static int CompareByAverage(Result x, Result y) + { + Debug.Assert(x != null, nameof(x) + " != null"); + Debug.Assert(y != null, nameof(y) + " != null"); + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (x.Average == null && y.Average == null) + return 0; + + if (x.Average == null) + return 1; + if (y.Average == null) + return -1; + + return decimal.Compare(x.Average.Value, y.Average.Value); + } + + private static int ComareByAttempts(Result x, Result y) + { + var bestAttemptX = x.Attempts?.Where(a => a != null).Min(); + var bestAttemptY = y.Attempts?.Where(a => a != null).Min(); + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (bestAttemptX == null && bestAttemptY == null) + return 0; + + if (bestAttemptX == null) + return 1; + if (bestAttemptY == null) + return -1; + + return decimal.Compare(bestAttemptX.Value, bestAttemptY.Value); + } + + public enum Mode + { + Single, Average + } + } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/Session.cs b/Backend/Data/Entities/Session.cs new file mode 100644 index 0000000..94367d8 --- /dev/null +++ b/Backend/Data/Entities/Session.cs @@ -0,0 +1,15 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Backend.Data.Entities +{ + public class Session + { + [Required] + public User User { get; set; } + [Required] + public string Token { get; set; } + [Required] + public DateTime Created { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/UpdatePasswordRequest.cs b/Backend/Data/Entities/UpdatePasswordRequest.cs new file mode 100644 index 0000000..9b68799 --- /dev/null +++ b/Backend/Data/Entities/UpdatePasswordRequest.cs @@ -0,0 +1,7 @@ +namespace Backend.Data.Entities +{ + public class UpdatePasswordRequest + { + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Backend/Data/Entities/User.cs b/Backend/Data/Entities/User.cs index 9dfbb48..e8a7626 100644 --- a/Backend/Data/Entities/User.cs +++ b/Backend/Data/Entities/User.cs @@ -1,8 +1,52 @@ -namespace Backend.Data.Entities +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Backend.Data.Entities { public class User { - public int Id { get; set; } + public const string RoleAdmin = "Admin"; + + public int? Id { get; set; } + [Required] public string FirstName { get; set; } + [Required] + public string LastName { get; set; } + public string City { get; set; } + // ReSharper disable once InconsistentNaming + public string WCAID { get; set; } + public string PhoneNumber { get; set; } + [Required] + public Gender? Gender { get; set; } + public DateTime? BirthDate { get; set; } + public DateTime? Approved { get; set; } + [Required] + public string Email { get; set; } + public IEnumerable Roles { get; set; } + + protected bool Equals(User other) + { + return Id == other.Id; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((User) obj); + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool IsAdmin() + { + return Roles != null && Roles.Contains(RoleAdmin); + } } } \ No newline at end of file diff --git a/Backend/Data/Repositories/BaseRepository.cs b/Backend/Data/Repositories/BaseRepository.cs index 6ccfa04..e480e0e 100644 --- a/Backend/Data/Repositories/BaseRepository.cs +++ b/Backend/Data/Repositories/BaseRepository.cs @@ -11,6 +11,36 @@ protected BaseRepository(DatabaseContext databaseContext) } private DatabaseContext DatabaseContext { get; } - protected MySqlConnection Connection => DatabaseContext.Connection; + public MySqlConnection Connection => DatabaseContext.Connection; + + public static class Db + { + public const string MeetingKey = "meeting"; + public static class Meeting + { + public const string MeetingIdKey = "meeting_id"; + } + + public const string UserKey = "user"; + public static class User + { + public const string UserIdKey = "user_id"; + public const string FirstNameKey = "first_name"; + public const string LastNameKey = "last_name"; + public const string GenderKey = "gender"; + public const string EmailKey = "email"; + public const string CityKey = "city"; + public const string WcaIdKey = "wca_id"; + public const string PhoneNumberKey = "phone_number"; + public const string BirthDateKey = "birth_date"; + } + + public const string LoginKey = "login"; + public static class Login + { + public const string UserIdKey = "user_id"; + public const string PasswordHashKey = "password_hash"; + } + } } } \ No newline at end of file diff --git a/Backend/Data/Repositories/DisiplinesRepository.cs b/Backend/Data/Repositories/DisiplinesRepository.cs new file mode 100644 index 0000000..11505eb --- /dev/null +++ b/Backend/Data/Repositories/DisiplinesRepository.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using Backend.Data.Database; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class DisciplinesRepository : BaseRepository + { + public DisciplinesRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public IEnumerable GetDisciplines() + { + using (var command = new MySqlCommand("select * from discipline", Connection)) + using (var reader = command.ExecuteReader()) + { + return ReadDisciplines(reader); + } + } + + public Discipline GetDiscipline(int id, MySqlTransaction transaction = null, bool readCounting = false) + { + const string disciplineIdKey = "discipline_id"; + + using (var command = new MySqlCommand($"select * from discipline where {disciplineIdKey} = @{disciplineIdKey}", Connection, transaction) + { + Parameters = + { + new MySqlParameter(disciplineIdKey, id) + } + }) + using (var reader = command.ExecuteReader()) + { + return reader.Read() ? GetDiscipline(reader, readCounting) : null; + } + } + + public static Discipline GetDiscipline(MySqlDataReader reader, bool readCounting = false) + { + var discipline = new Discipline + { + Id = reader.GetInt32("discipline_id"), + Name = reader.GetString("name"), + Description = reader.GetString("description") + }; + if (readCounting) + { + discipline.Counting = reader.GetString("counting"); + } + return discipline; + } + + private static IEnumerable ReadDisciplines(MySqlDataReader reader) + { + var disciplines = new List(); + + while (reader.Read()) + { + disciplines.Add(GetDiscipline(reader)); + } + + return disciplines; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/MeetingsRepository.cs b/Backend/Data/Repositories/MeetingsRepository.cs new file mode 100644 index 0000000..3dbb6e2 --- /dev/null +++ b/Backend/Data/Repositories/MeetingsRepository.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using Backend.Data.Database; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class MeetingsRepository : BaseRepository + { + public MeetingsRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public IEnumerable GetMeetings() + { + using (var command = new MySqlCommand("select * from meeting order by date desc", Connection)) + using (var reader = command.ExecuteReader()) + { + var meetings = new List(); + + while (reader.Read()) + { + meetings.Add(GetMeeting(reader)); + } + + return meetings; + } + } + + public Meeting GetMeeting(int id) + { + using (var command = + new MySqlCommand("select * from meeting where meeting_id = @meeting_id", Connection) + { + Parameters = {new MySqlParameter("meeting_id", id)} + }) + using (var reader = command.ExecuteReader()) + { + return ReadMeeting(reader); + } + } + + public Meeting GetLastMeeting() + { + using (var command = new MySqlCommand("select * from meeting order by date desc limit 1", Connection)) + using (var reader = command.ExecuteReader()) + { + return ReadMeeting(reader); + } + } + + public static Meeting GetMeeting(MySqlDataReader reader) + { + return new Meeting + { + Id = reader.GetInt32("meeting_id"), + Number = reader.GetInt32("meeting_number"), + Date = reader.GetDateTime("date") + }; + } + + public void AddMeeting(Meeting meeting, MySqlTransaction transaction) + { + const string meetingNumberKey = "meeting_number"; + const string dateKey = "date"; + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into meeting({meetingNumberKey}, {dateKey}) " + + $"values(@{meetingNumberKey}, @{dateKey})", + Parameters = + { + new MySqlParameter(meetingNumberKey, meeting.Number), + new MySqlParameter(dateKey, meeting.Date) + } + }) + { + command.ExecuteNonQuery(); + + meeting.Id = (int) command.LastInsertedId; + } + } + + public static Meeting ReadMeeting(MySqlDataReader reader) + { + return reader.Read() ? GetMeeting(reader) : null; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/NewsRepository.cs b/Backend/Data/Repositories/NewsRepository.cs new file mode 100644 index 0000000..0528217 --- /dev/null +++ b/Backend/Data/Repositories/NewsRepository.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using Backend.Data.Database; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + public class NewsRepository : BaseRepository + { + public NewsRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public IEnumerable GetNews() + { + using (var command = new MySqlCommand("select * from news n " + + "inner join user u on n.user_id = u.user_id order by n.date_and_time desc;", Connection)) + using (var reader = command.ExecuteReader()) + { + var news = new List(); + + while (reader.Read()) + { + news.Add(GetNews(reader)); + } + + return news; + } + } + + public News GetNews(int id) + { + using (var command = + new MySqlCommand("select * from news where news_id = @news_id", Connection) + { + Parameters = {new MySqlParameter("news_id", id)} + }) + + using (var reader = command.ExecuteReader()) + { + return ReadNews(reader); + } + } + + public News GetLastNews() + { + using (var command = new MySqlCommand("select * from news order by date desc limit 1", Connection)) + using (var reader = command.ExecuteReader()) + { + return ReadNews(reader); + } + } + + public static News GetNews(MySqlDataReader reader) + { + return new News + { + Id = reader.GetInt32("news_id"), + User = UserRepository.GetUser(reader, false), + Text = reader.GetString("text"), + DateAndTime = reader.GetDateTime("date_and_time") + }; + } + + public void AddNews(News news, MySqlTransaction transaction) + { + const string userIdKey = "user_id"; + const string messageNewsTextKey = "text"; + const string addNewsDateKey = "date_and_time"; + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into news({userIdKey},{messageNewsTextKey}, {addNewsDateKey}) " + + $"values(@{userIdKey}, @{messageNewsTextKey}, @{addNewsDateKey})", + Parameters = + { + new MySqlParameter(userIdKey, news.User.Id), + new MySqlParameter(messageNewsTextKey, news.Text), + new MySqlParameter(addNewsDateKey, news.DateAndTime) + } + }) + { + command.ExecuteNonQuery(); + + news.Id = (int) command.LastInsertedId; + } + } + + public static News ReadNews(MySqlDataReader reader) + { + return reader.Read() ? GetNews(reader) : null; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/ResultsRepository.cs b/Backend/Data/Repositories/ResultsRepository.cs new file mode 100644 index 0000000..ca10c7b --- /dev/null +++ b/Backend/Data/Repositories/ResultsRepository.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Backend.Data.Database; +using Backend.Data.Database.Entities; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class ResultsRepository : BaseRepository + { + public ResultsRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public IEnumerable GetResults((int? meetingId, int? userId) filter, bool readMeeting = false, bool readDiscipline = false, bool readUser = false) + { + using (var command = new MySqlCommand(Connection, null)) + { + var whereClauseValues = new List<(string key, object value)>(); + + // check is meeting exists in the db + if (filter.meetingId != null) + { + whereClauseValues.Add((Db.Meeting.MeetingIdKey, filter.meetingId.Value)); + + command.Parameters.Clear(); + command.Parameters.Add(new MySqlParameter(Db.Meeting.MeetingIdKey, filter.meetingId.Value)); + + command.CommandText = $"select {Db.Meeting.MeetingIdKey} from {Db.MeetingKey} where {Db.Meeting.MeetingIdKey} = @{Db.Meeting.MeetingIdKey}"; + + var exists = (int?) command.ExecuteScalar(); + if (!exists.HasValue) + return null; + } + + // check is user exists in the db + if (filter.userId != null) + { + whereClauseValues.Add((Db.User.UserIdKey, filter.userId.Value)); + + command.Parameters.Clear(); + command.Parameters.Add(new MySqlParameter(Db.User.UserIdKey, filter.userId.Value)); + + command.CommandText = $"select {Db.User.UserIdKey} from {Db.UserKey} where {Db.User.UserIdKey} = @{Db.User.UserIdKey}"; + + var exists = (int?) command.ExecuteScalar(); + if (!exists.HasValue) + return null; + } + + // read results + command.Parameters.Clear(); + + var whereClause = new StringBuilder(); + if (whereClauseValues.Count > 0) + { + whereClause.Append("where"); + + for (var i = 0; i < whereClauseValues.Count; i++) + { + var pair = whereClauseValues[i]; + + if (i > 0) + whereClause.Append(" and"); + + command.Parameters.Add(new MySqlParameter(pair.key, pair.value)); + + whereClause.Append($" {pair.key} = @{pair.key}"); + } + } + + command.CommandText = $"select * from meeting_results {whereClause}"; + + using (var reader = command.ExecuteReader()) + { + var resultAttemptsDictionary = new Dictionary>(); + + while (reader.Read()) + { + var result = GetResult(reader); + List attempts; + + if (!resultAttemptsDictionary.ContainsKey(result)) + { + if (readMeeting) + result.Meeting = MeetingsRepository.GetMeeting(reader); + if (readDiscipline) + result.Discipline = DisciplinesRepository.GetDiscipline(reader); + if (readUser) + result.User = UserRepository.GetUser(reader, false); + + attempts = new List(); + + resultAttemptsDictionary.Add(result, attempts); + } + else + { + attempts = resultAttemptsDictionary[result]; + } + + var attempt = GetAttempt(reader); + if (attempt != null) + { + attempts.Add(attempt); + } + } + + return resultAttemptsDictionary.Select(pair => + { + var result = pair.Key; + + result.Attempts = pair.Value.Select(attempt => attempt.Time).ToList(); + + return result; + }); + } + } + } + + private static Result GetResult(MySqlDataReader reader) + { + return new Result + { + Id = reader.GetInt32("result_id"), + Average = !reader.IsDBNull(reader.GetOrdinal("average")) ? (decimal?)reader.GetDecimal("average") : null, + AttemptCount = reader.GetInt32("attempt_count") + }; + } + + private static Attempt GetAttempt(MySqlDataReader reader) + { + const string attemptIdKey = "attempt_id"; + + if (reader.IsDBNull(reader.GetOrdinal(attemptIdKey))) + return null; + + return new Attempt + { + Id = reader.GetInt32(attemptIdKey), + Time = !reader.IsDBNull(reader.GetOrdinal("time")) ? (decimal?)reader.GetDecimal("time") : null + }; + } + + public void AddResult(Result result, MySqlTransaction transaction) + { + const string userIdKey = "user_id"; + const string meetingIdKey = "meeting_id"; + const string disciplineIdKey = "discipline_id"; + const string averageKey = "average"; + const string attemptCountKey = "attempt_count"; + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into result({averageKey}, {userIdKey}, {meetingIdKey}, {disciplineIdKey}, {attemptCountKey}) " + + $"values(@{averageKey}, @{userIdKey}, @{meetingIdKey}, @{disciplineIdKey}, @{attemptCountKey})", + Parameters = + { + new MySqlParameter(userIdKey, result.User.Id), + new MySqlParameter(meetingIdKey, result.Meeting.Id), + new MySqlParameter(disciplineIdKey, result.Discipline.Id), + new MySqlParameter(averageKey, result.Average), + new MySqlParameter(attemptCountKey, result.AttemptCount) + } + }) + { + command.ExecuteNonQuery(); + + result.Id = (int) command.LastInsertedId; + } + + foreach (var attemptTime in result.Attempts) + { + var attempt = new Attempt + { + Result = result, + Time = attemptTime + }; + + AddAttempt(attempt, transaction); + } + } + + private void AddAttempt(Attempt attempt, MySqlTransaction transaction) + { + const string resultIdKey = "result_id"; + const string timeKey = "time"; + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into attempt({resultIdKey}, {timeKey}) " + + $"values(@{resultIdKey}, @{timeKey})", + Parameters = + { + new MySqlParameter(resultIdKey, attempt.Result.Id), + new MySqlParameter(timeKey, attempt.Time) + } + }) + { + command.ExecuteNonQuery(); + + attempt.Id = (int) command.LastInsertedId; + } + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/SessionRepository.cs b/Backend/Data/Repositories/SessionRepository.cs new file mode 100644 index 0000000..a59c03c --- /dev/null +++ b/Backend/Data/Repositories/SessionRepository.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using Backend.Data.Database; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class SessionRepository : BaseRepository + { + public SessionRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public void AddSession(Session session, MySqlTransaction transaction) + { + const string userIdKey = "user_id"; + const string tokenKey = "session_key"; + const string createdKey = "created"; + + session.Created = DateTime.Now; + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into session({userIdKey}, {tokenKey}, {createdKey}) " + + $"values(@{userIdKey}, @{tokenKey}, @{createdKey})", + Parameters = + { + new MySqlParameter(userIdKey, session.User.Id), + new MySqlParameter(tokenKey, session.Token), + new MySqlParameter(createdKey, session.Created) + } + }) + { + command.ExecuteNonQuery(); + } + } + + public Session GetSessionByToken(string token) + { + const string tokenKey = "session_key"; + + Session session; + + using (var command = + new MySqlCommand($"select * from session_user where {tokenKey} = @{tokenKey}", Connection) + { + Parameters = {new MySqlParameter(tokenKey, token)} + }) + using (var reader = command.ExecuteReader()) + { + session = ReadSession(reader); + } + + var user = session.User; + + Debug.Assert(user.Id != null, "user.Id != null"); + user.Roles = UserRepository.ReadRoles(Connection, user.Id.Value); + + return session; + } + + public static Session ReadSession(MySqlDataReader reader) + { + return reader.Read() ? GetSession(reader) : null; + } + + public static Session GetSession(MySqlDataReader reader) + { + var user = UserRepository.GetUser(reader, true, true); + + return new Session + { + Created = reader.GetDateTime("created"), + Token = reader.GetString("session_key"), + User = user + }; + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/UserRepository.cs b/Backend/Data/Repositories/UserRepository.cs new file mode 100644 index 0000000..30defec --- /dev/null +++ b/Backend/Data/Repositories/UserRepository.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Backend.Data.Database; +using Backend.Data.Database.Entities; +using Backend.Data.Entities; +using MySql.Data.MySqlClient; + +namespace Backend.Data.Repositories +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class UserRepository : BaseRepository + { + public UserRepository(DatabaseContext databaseContext) : base(databaseContext) + { + } + + public IEnumerable GetUsers(bool readPrivateFields) + { + using (var command = new MySqlCommand("select * from user", Connection)) + using (var reader = command.ExecuteReader()) + { + var users = new List(); + + while (reader.Read()) + { + users.Add(GetUser(reader, readPrivateFields)); + } + + return users; + } + } + + public User GetUser(int id, bool readPrivateFields, MySqlTransaction transaction = null) + { + User user; + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"select * from user where {Db.User.UserIdKey} = @{Db.User.UserIdKey}", + Parameters = + { + new MySqlParameter(Db.User.UserIdKey, id) + } + }) + using (var reader = command.ExecuteReader()) + { + user = reader.Read() ? GetUser(reader, readPrivateFields) : null; + } + + if (user == null) + return null; + + Debug.Assert(user.Id != null, "user.Id != null"); + user.Roles = ReadRoles(Connection, user.Id.Value, transaction); + + return user; + } + + public User GetUserByEmail(string email, bool readPrivateFields, MySqlTransaction transaction) + { + User user; + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"select * from user where {Db.User.EmailKey} = @{Db.User.EmailKey}", + Parameters = + { + new MySqlParameter(Db.User.EmailKey, email) + } + }) + using (var reader = command.ExecuteReader()) + { + user = reader.Read() ? GetUser(reader, readPrivateFields) : null; + } + + if (user == null) return null; + + Debug.Assert(user.Id != null, "user.Id != null"); + user.Roles = ReadRoles(Connection, user.Id.Value, transaction); + + return user; + } + + public void UpdateUser(User user, MySqlTransaction transaction) + { + Debug.Assert(user.Id != null, "user.Id != null"); + + using (var command = new MySqlCommand(Connection, transaction)) + { + var parameters = new List<(string key, object value)> + { + (Db.User.FirstNameKey, user.FirstName), + (Db.User.LastNameKey, user.LastName), + (Db.User.CityKey, user.City), + (Db.User.WcaIdKey, user.WCAID), + (Db.User.PhoneNumberKey, user.PhoneNumber), + (Db.User.GenderKey, GenderToSqlString(user.Gender)), + (Db.User.BirthDateKey, user.BirthDate) + }; + + var sb = new StringBuilder("update user set").AppendLine(); + + for (var i = 0; i < parameters.Count; i++) + { + var pair = parameters[i]; + var key = pair.key; + var value = pair.value; + + sb.Append($" {key} = @{key}"); + if (i < parameters.Count - 1) + sb.Append(","); + sb.AppendLine(); + + command.Parameters.Add(new MySqlParameter(key, value)); + } + + sb.AppendLine($"where {Db.User.UserIdKey} = @{Db.User.UserIdKey}"); + command.Parameters.Add(new MySqlParameter(Db.User.UserIdKey, user.Id.Value)); + + command.CommandText = sb.ToString(); + + command.ExecuteNonQuery(); + } + } + + public static User GetUser(MySqlDataReader reader, bool readPrivateFields, bool readAdminFields = false) + { + var user = new User + { + Id = reader.GetInt32("user_id"), + FirstName = reader.GetString("first_name"), + LastName = reader.GetString("last_name"), + Gender = ParseGenderString(reader.GetString("gender")) + }; + + if (readPrivateFields) + { + user.City = !reader.IsDBNull(reader.GetOrdinal("city")) ? reader.GetString("city") : null; + user.WCAID = !reader.IsDBNull(reader.GetOrdinal("wca_id")) ? reader.GetString("wca_id") : null; + user.PhoneNumber = !reader.IsDBNull(reader.GetOrdinal("phone_number")) ? reader.GetString("phone_number") : null; + user.BirthDate = !reader.IsDBNull(reader.GetOrdinal("birth_date")) ? (DateTime?)reader.GetDateTime("birth_date") : null; + } + + if (readAdminFields) + { + user.Approved = !reader.IsDBNull(reader.GetOrdinal("approved")) ? (DateTime?)reader.GetDateTime("approved") : null; + } + + return user; + } + + private static Gender ParseGenderString(string genderString) + { + if (!Enum.TryParse(genderString, true, out Gender gender)) + throw new Exception(); + + return gender; + } + + public void AddUser(User user, MySqlTransaction transaction) + { + Debug.Assert(user.Gender != null, "user.Gender != null"); + + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into user({Db.User.FirstNameKey}, {Db.User.LastNameKey}, {Db.User.GenderKey}, {Db.User.EmailKey}) " + + $"values(@{Db.User.FirstNameKey}, @{Db.User.LastNameKey}, @{Db.User.GenderKey}, @{Db.User.EmailKey})", + Parameters = + { + new MySqlParameter(Db.User.FirstNameKey, user.FirstName), + new MySqlParameter(Db.User.LastNameKey, user.LastName), + new MySqlParameter(Db.User.GenderKey, GenderToSqlString(user.Gender.Value)), + new MySqlParameter(Db.User.EmailKey, user.Email) + } + }) + { + command.ExecuteNonQuery(); + + user.Id = (int) command.LastInsertedId; + user.Roles = new List(); + } + } + + public void AddLogin(User user, byte[] passwordHash, MySqlTransaction transaction) + { + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"insert into {Db.LoginKey}({Db.Login.UserIdKey}, {Db.Login.PasswordHashKey}) " + + $"values(@{Db.Login.UserIdKey}, @{Db.Login.PasswordHashKey})", + Parameters = + { + new MySqlParameter(Db.Login.UserIdKey, user.Id), + new MySqlParameter(Db.Login.PasswordHashKey, passwordHash) + } + }) + { + command.ExecuteNonQuery(); + + user.Id = (int) command.LastInsertedId; + } + } + + public Login GetLogin(int userId, MySqlTransaction transaction) + { + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"select * from {Db.LoginKey} where {Db.User.UserIdKey} = @{Db.User.UserIdKey}", + Parameters = + { + new MySqlParameter(Db.User.UserIdKey, userId) + } + }) + using (var reader = command.ExecuteReader()) + { + return reader.Read() ? GetLogin(reader) : null; + } + } + + private static Login GetLogin(MySqlDataReader reader) + { + var login = new Login + { + Id = reader.GetInt32("user_id") + }; + + var buffer = new byte[20]; + + var read = reader.GetBytes(reader.GetOrdinal("password_hash"), 0, buffer, 0, buffer.Length); + + login.Hash = new byte[read]; + + for (var i = 0; i < read; i++) + { + login.Hash[i] = buffer[i]; + } + + return login; + } + + private static string GenderToSqlString(Gender? gender) + { + if (gender == null) return null; + + return gender == Gender.Male ? "male" : "female"; + } + + public static IEnumerable ReadRoles(MySqlConnection connection, int userId, MySqlTransaction transaction = null) + { + using (var command = new MySqlCommand(connection, transaction) + { + CommandText = + $"select r.name from user_role ur join role r on ur.role_id = r.role_id where {Db.User.UserIdKey} = @{Db.User.UserIdKey}", + Parameters = + { + new MySqlParameter(Db.User.UserIdKey, userId) + } + }) + using (var reader = command.ExecuteReader()) + { + var roles = new List(); + while (reader.Read()) + { + roles.Add(reader["name"].ToString()); + } + + return roles; + } + } + + public void UpdatePassword(int userId, byte[] passwordHash, MySqlTransaction transaction = null) + { + using (var command = new MySqlCommand(Connection, transaction) + { + CommandText = $"update {Db.LoginKey} set {Db.Login.PasswordHashKey} = @{Db.Login.PasswordHashKey} where {Db.Login.UserIdKey} = @{Db.Login.UserIdKey}", + Parameters = + { + new MySqlParameter(Db.Login.UserIdKey, userId), + new MySqlParameter(Db.Login.PasswordHashKey, passwordHash) + } + }) + { + command.ExecuteNonQuery(); + } + } + } +} \ No newline at end of file diff --git a/Backend/Data/Repositories/UsersRepository.cs b/Backend/Data/Repositories/UsersRepository.cs deleted file mode 100644 index a3fd5a7..0000000 --- a/Backend/Data/Repositories/UsersRepository.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Backend.Data.Database; -using Backend.Data.Entities; -using MySql.Data.MySqlClient; - -namespace Backend.Data.Repositories -{ - // ReSharper disable once ClassNeverInstantiated.Global - public class UsersRepository : BaseRepository - { - public UsersRepository(DatabaseContext databaseContext) : base(databaseContext) - { - } - - public async Task> GetUsersAsync() - { - using (var transaction = await Connection.BeginTransactionAsync()) - using (var command = new MySqlCommand("select * from user", Connection, transaction)) - using (var reader = await command.ExecuteReaderAsync()) - { - var users = new List(); - - while (await reader.ReadAsync()) - { - users.Add(new User - { - Id = reader.GetInt32(reader.GetOrdinal("user_id")), - FirstName = reader.GetString(reader.GetOrdinal("first_name")) - }); - } - - return users; - } - } - } -} \ No newline at end of file diff --git a/Backend/Domain/DisciplinesManager.cs b/Backend/Domain/DisciplinesManager.cs new file mode 100644 index 0000000..961da2a --- /dev/null +++ b/Backend/Domain/DisciplinesManager.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using Backend.Data.Entities; +using Backend.Data.Repositories; + +namespace Backend.Domain +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class DisciplinesManager + { + private readonly DisciplinesRepository _disciplinesRepository; + + public DisciplinesManager(DisciplinesRepository disciplinesRepository) + { + _disciplinesRepository = disciplinesRepository; + } + + public IEnumerable GetDisciplinesAsync() + { + return _disciplinesRepository.GetDisciplines(); + } + } +} \ No newline at end of file diff --git a/Backend/Domain/Formula/CountingFormula.cs b/Backend/Domain/Formula/CountingFormula.cs new file mode 100644 index 0000000..1723f16 --- /dev/null +++ b/Backend/Domain/Formula/CountingFormula.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Backend.Domain.Formula +{ + public abstract class CountingFormula + { + public int AttemptCount { get; } + + private CountingFormula(int attemptCount) + { + AttemptCount = attemptCount; + } + + public abstract decimal? ComputeAverage(IEnumerable attempts); + + public static CountingFormula Get(string counting) + { + switch (counting) + { + case "avg5": + return new Avg5(); + case "mo3": + return new Mo3(); + case "bo3": + return new Bo3(); + default: + throw new Exception("Unknown counting"); + } + } + + private class Avg5 : CountingFormula + { + public Avg5() : base(5) + { + } + + public override decimal? ComputeAverage(IEnumerable attempts) + { + var orderedAttempts = attempts.OrderBy(a => a).ToList(); + + if (orderedAttempts.Count > AttemptCount) + throw new Exception("Too much attempts provided"); + + var nullsCount = orderedAttempts.Count(arg => arg == null) + (AttemptCount - orderedAttempts.Count); + + if (nullsCount > 1) + return null; + + orderedAttempts.RemoveAt(orderedAttempts.Count - 1); + orderedAttempts.RemoveAt(0); + + return orderedAttempts.Average(); + } + } + + private class Mo3 : CountingFormula + { + public Mo3() : base(3) + { + } + + public override decimal? ComputeAverage(IEnumerable attempts) + { + // todo fix it + return attempts.Average(); + } + } + + private class Bo3 : CountingFormula + { + public Bo3() : base(3) + { + } + + public override decimal? ComputeAverage(IEnumerable attempts) + { + // todo fix it + return attempts.Min(); + } + } + } +} \ No newline at end of file diff --git a/Backend/Domain/MeetingsManager.cs b/Backend/Domain/MeetingsManager.cs new file mode 100644 index 0000000..f3d7a66 --- /dev/null +++ b/Backend/Domain/MeetingsManager.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Backend.Data.Database; +using Backend.Data.Entities; +using Backend.Data.Repositories; + +namespace Backend.Domain +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class MeetingsManager + { + private readonly DatabaseContext _databaseContext; + private readonly MeetingsRepository _meetingsRepository; + + public MeetingsManager(DatabaseContext databaseContext, MeetingsRepository meetingsRepository) + { + _databaseContext = databaseContext; + _meetingsRepository = meetingsRepository; + } + + public IEnumerable GetMeetings() + { + return _meetingsRepository.GetMeetings(); + } + + public Meeting GetMeeting(int id) + { + return _meetingsRepository.GetMeeting(id); + } + + public Meeting GetLastMeeting() + { + return _meetingsRepository.GetLastMeeting(); + } + + public void AddMeeting(Meeting meeting) + { + _databaseContext.UseTransaction(transaction => + _meetingsRepository.AddMeeting(meeting, transaction) + ); + } + } +} \ No newline at end of file diff --git a/Backend/Domain/NewsManager.cs b/Backend/Domain/NewsManager.cs new file mode 100644 index 0000000..6a809b6 --- /dev/null +++ b/Backend/Domain/NewsManager.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Backend.Data.Database; +using Backend.Data.Entities; +using Backend.Data.Repositories; + +namespace Backend.Domain +{ + public class NewsManager + { + private readonly DatabaseContext _databaseContext; + private readonly NewsRepository _newsRepository; + + public NewsManager(DatabaseContext databaseContext, NewsRepository newsRepository) + { + _databaseContext = databaseContext; + _newsRepository = newsRepository; + } + + public IEnumerable GetNewsAsyns() + { + return _newsRepository.GetNews(); + } + + public News GetNews(int id) + { + return _newsRepository.GetNews(id); + } + + public News GetLastNews() + { + return _newsRepository.GetLastNews(); + } + + public void AddNews(News news) + { + if (news.DateAndTime == null) + news.DateAndTime = DateTime.Now; + + _databaseContext.UseTransaction(transaction => + _newsRepository.AddNews(news, transaction) + ); + } + } +} \ No newline at end of file diff --git a/Backend/Domain/ResultsManager.cs b/Backend/Domain/ResultsManager.cs new file mode 100644 index 0000000..1aed721 --- /dev/null +++ b/Backend/Domain/ResultsManager.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Backend.Data.Database; +using Backend.Data.Entities; +using Backend.Data.Repositories; +using Backend.Domain.Formula; + +namespace Backend.Domain +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class ResultsManager + { + private readonly DatabaseContext _databaseContext; + private readonly DisciplinesRepository _disciplinesRepository; + private readonly ResultsRepository _resultsRepository; + + public ResultsManager(DatabaseContext databaseContext, ResultsRepository resultsRepository, DisciplinesRepository disciplinesRepository) + { + _databaseContext = databaseContext; + _resultsRepository = resultsRepository; + _disciplinesRepository = disciplinesRepository; + } + + public IEnumerable GetMeetingResults(int meetingId) + { + var results = _resultsRepository.GetResults(filter: (meetingId, null), readDiscipline: true, readUser: true); + return GroupResultsByDiscipline(results); + } + + public IEnumerable GetNewsResults(int newsId) + { + var results = _resultsRepository.GetResults(filter: (newsId, null), readDiscipline: true, readUser: true); + return GroupResultsByDiscipline(results); + } + + public IEnumerable GetUserResults(int userId) + { + var results = _resultsRepository.GetResults(filter: (null, userId), readDiscipline: true, readMeeting: true); + return GroupResultsByDiscipline(results); + } + + public IEnumerable GetUserRecords(int userId) + { + var averageResultComparer = new Result.Comparer(Result.Comparer.Mode.Average); + var singleResultComparer = new Result.Comparer(Result.Comparer.Mode.Single); + + var results = _resultsRepository.GetResults(filter: (null, userId), readDiscipline: true, readMeeting: true); + return GroupResultsByDiscipline(results) + .Select(dr => new DisciplineRecord + { + Discipline = dr.Discipline, + BestSingleResult = dr.Results.OrderBy(r => r, singleResultComparer).FirstOrDefault(), + BestAverageResult = dr.Results.OrderBy(r => r, averageResultComparer).FirstOrDefault() + }); + } + + public IEnumerable GetRankings(FilterType type, SortType sort, Gender? gender) + { + var averageResultComparer = new Result.Comparer(Result.Comparer.Mode.Average); + var singleResultComparer = new Result.Comparer(Result.Comparer.Mode.Single); + + var results = _resultsRepository.GetResults(filter: (null, null), readDiscipline: true, readMeeting: true, readUser: true); + + if (gender != null) + results = results.Where(r => r.User.Gender == gender); + + var disciplineResults = GroupResultsByDiscipline(results) + .Select(dr => + { + var orderedResults = dr.Results + .OrderBy(r => r, type == FilterType.Average ? averageResultComparer : singleResultComparer) + .Distinct(new ResultUserEqualityComparer()); + if (sort == SortType.Descending) + orderedResults = orderedResults.Reverse(); + + return new DisciplineResults + { + Discipline = dr.Discipline, + Results = orderedResults + }; + }); + + return disciplineResults; + } + + private static IEnumerable GroupResultsByDiscipline(IEnumerable results) + { + return results? + .GroupBy(pair => pair.Discipline) + .Select(pairs => + { + var discipline = pairs.Key; + + discipline.Description = null; + + return new DisciplineResults + { + Discipline = discipline, + Results = pairs.Select(result => + { + result.Discipline = null; + return result; + }) + }; + }); + } + + public void AddResult(Result result) + { + if (result == null) throw new ArgumentNullException(nameof(result)); + + _databaseContext.UseTransaction(transaction => + { + var discipline = _disciplinesRepository.GetDiscipline(result.Discipline.Id, transaction, true); + if (discipline == null) + throw new ArgumentException("Bad discipline id is provided"); + + var countingFormula = CountingFormula.Get(discipline.Counting); + + var attempts = result.Attempts; + var attemptCount = countingFormula.AttemptCount; + + result.Average = countingFormula.ComputeAverage(attempts); + result.AttemptCount = attemptCount; + + _resultsRepository.AddResult(result, transaction); + }); + } + + public enum FilterType + { + Average, Single + } + + public enum SortType + { + Ascending, Descending + } + } + + public class ResultUserEqualityComparer : IEqualityComparer + { + public bool Equals(Result x, Result y) + { + return x.User.Equals(y.User); + } + + public int GetHashCode(Result obj) + { + return obj.User.GetHashCode(); + } + } +} diff --git a/Backend/Domain/UsersManager.cs b/Backend/Domain/UsersManager.cs index aa771f3..cd37463 100644 --- a/Backend/Domain/UsersManager.cs +++ b/Backend/Domain/UsersManager.cs @@ -1,5 +1,10 @@ -using System.Collections.Generic; -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Backend.Data.Database; using Backend.Data.Entities; using Backend.Data.Repositories; @@ -8,16 +13,127 @@ namespace Backend.Domain // ReSharper disable once ClassNeverInstantiated.Global public class UsersManager { - private readonly UsersRepository _usersRepository; + private readonly UserRepository _userRepository; + private readonly DatabaseContext _databaseContext; + private readonly SessionRepository _sessionRepository; - public UsersManager(UsersRepository usersRepository) + public UsersManager(UserRepository userRepository, DatabaseContext databaseContext, SessionRepository sessionRepository) { - _usersRepository = usersRepository; + _userRepository = userRepository; + _databaseContext = databaseContext; + _sessionRepository = sessionRepository; } - public async Task> GetUsersAsync() + public IEnumerable GetUsers(bool readPrivateFields) { - return await _usersRepository.GetUsersAsync(); + return _userRepository.GetUsers(readPrivateFields); + } + + public User GetUser(int id, bool readPrivateFields) + { + return _userRepository.GetUser(id, readPrivateFields); + } + + public Session Register(CreateUserRequest createUserRequest) + { + return _databaseContext.UseTransaction(transaction => + { + _userRepository.AddUser(createUserRequest.User, transaction); + _userRepository.AddLogin(createUserRequest.User, Hash(createUserRequest.Password), transaction); + + var session = CreateSession(createUserRequest.User); + + _sessionRepository.AddSession(session, transaction); + + return session; + }); + } + + public Session Login(CreateSessionRequest createSessionRequest) + { + return _databaseContext.UseTransaction(transaction => + { + var user = _userRepository.GetUserByEmail(createSessionRequest.Email, true, transaction); + if (user == null) + throw new Exception("User does not exist"); + + Debug.Assert(user.Id != null, "user.Id != null"); + var login = _userRepository.GetLogin(user.Id.Value, transaction); + if (login == null) + throw new Exception("Login does not exist"); + + var hash = Hash(createSessionRequest.Password); + + if (!login.Hash.SequenceEqual(hash)) + throw new Exception("Password is not correct"); + + var session = CreateSession(user); + + _sessionRepository.AddSession(session, transaction); + + return session; + }); + } + + public Session FindSession(string token) + { + return _sessionRepository.GetSessionByToken(token); + } + + public User UpdateUser(User user) + { + Debug.Assert(user.Id != null, "user.Id != null"); + + return _databaseContext.UseTransaction(transaction => + { + _userRepository.UpdateUser(user, transaction); + return _userRepository.GetUser(user.Id.Value, true, transaction); + }); + } + + private static byte[] Hash(string input) + { + return Hash(Encoding.UTF8.GetBytes(input)); + } + + private static byte[] Hash(byte[] input) + { + using (var sha1 = new SHA1Managed()) + { + return sha1.ComputeHash(input); + } + } + + private static string ConvertBytesToString(IEnumerable input) + { + var sb = new StringBuilder(); + foreach (var b in input) + { + sb.Append(b.ToString("X2")); + } + + return sb.ToString(); + } + + private static Session CreateSession(User user) + { + var random = new Random(); + + var bytes = new byte[40]; + random.NextBytes(bytes); + + var hash = Hash(bytes); + + return new Session + { + Token = ConvertBytesToString(hash), + User = user + }; + } + + public void UpdatePassword(int userId, string password) + { + _userRepository.UpdatePassword(userId, Hash(password)); } } } \ No newline at end of file diff --git a/Backend/Startup.cs b/Backend/Startup.cs index d8d2658..eecbb74 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -5,6 +5,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Swashbuckle.AspNetCore.Swagger; namespace Backend { @@ -20,11 +23,41 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + services + .AddMvc() + .AddJsonOptions(options => + { + var serializerSettings = options.SerializerSettings; + + serializerSettings.NullValueHandling = NullValueHandling.Ignore; + serializerSettings.Converters.Add(new StringEnumConverter(true)); + serializerSettings.DateFormatString = "yyyy-MM-dd'T'HH:mm:ss"; + }); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new Info {Title = "KhSM API", Version = "v1"}); + }); + + ConfigDataServices(services); + } + + public static void ConfigDataServices(IServiceCollection services) + { services.AddScoped(); - services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -35,6 +68,12 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) app.UseDeveloperExceptionPage(); } + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "KhSM API v1"); + }); + app.UseMvc(); } } diff --git a/Database/Dump20180314.zip b/Database/Dump20180314.zip new file mode 100644 index 0000000..3c854ff Binary files /dev/null and b/Database/Dump20180314.zip differ diff --git a/Database/Initialize.sql b/Database/Initialize.sql index b0c4a8a..be8a418 100644 --- a/Database/Initialize.sql +++ b/Database/Initialize.sql @@ -7,8 +7,134 @@ create user 'kh_sm'@'localhost' identified by 'sKiLlet'; grant select, insert, update, delete on kh_sm.* TO 'kh_sm'@'localhost'; + +-- tables + +create table Gender +( + gender varchar(8) primary key not null +); + create table User ( - user_id int primary key auto_increment, - first_name varchar(32) not null -); \ No newline at end of file + user_id int primary key auto_increment not null, + first_name varchar(32) not null, + last_name varchar(32) not null, + city varchar(32) null, + wca_id varchar(16) null, + phone_number varchar(32) null, + gender varchar(8) not null, + birth_date date null, + approved date null, + email varchar(64) not null unique, + foreign key (gender) references Gender(gender) +); + +create table Meeting +( + meeting_id int primary key auto_increment not null, + meeting_number int not null, + `date` datetime not null unique +); + +create table News +( + news_id int primary key auto_increment not null, + user_id int not null, + `text` text not null, + date_and_time datetime not null, + foreign key (user_id) references user(user_id) +); + +create table Counting +( + counting varchar(8) primary key not null +); + +create table Discipline +( + discipline_id int primary key auto_increment not null, + `name` varchar(32) not null, + description text null, + counting varchar(8) not null, + foreign key (counting) references Counting(counting) +); + +create table Result +( + result_id int primary key auto_increment not null, + average decimal(5, 2) null, + meeting_id int not null, + user_id int not null, + discipline_id int not null, + attempt_count int not null, + foreign key (meeting_id) references Meeting(meeting_id), + foreign key (user_id) references User(user_id), + foreign key (discipline_id) references Discipline(discipline_id), + unique(meeting_id, user_id, discipline_id) +); + +create table Attempt +( + attempt_id int primary key auto_increment not null, + result_id int not null, + `time` decimal(5, 2) null, + foreign key (result_id) references Result(result_id) +); + +create table Role +( + role_id int primary key auto_increment not null, + `name` varchar(16) not null unique, + role_key varchar(16) not null unique +); + +create table User_Role +( + user_id int primary key auto_increment not null, + role_id int not null, + foreign key (user_id) references User(user_id), + foreign key (role_id) references Role(role_id) +); + +create table Login +( + user_id int primary key auto_increment not null unique, + password_hash binary(20) not null, + disabled date null, + foreign key (user_id) references User(user_id) +); + +create table `Session` +( + session_id int primary key auto_increment not null, + user_id int not null, + session_key varchar(64) not null unique, + created datetime not null, + foreign key (user_id) references Login(user_id) +); + +-- views + +create view meeting_results as +select + m.meeting_id, m.meeting_number, m.date, + d.discipline_id, d.name, d.description, + r.result_id, r.average, r.attempt_count, + u.user_id, u.first_name, u.last_name, u.city, u.gender, u.wca_id, u.phone_number, u.birth_date, u.approved, + a.attempt_id, a.time +from meeting m + inner join result r on m.meeting_id = r.meeting_id + inner join discipline d on r.discipline_id = d.discipline_id + inner join user u on r.user_id = u.user_id + left join attempt a on r.result_id = a.result_id +order by m.meeting_id, d.discipline_id, r.average; + +create view session_user as +select + s.session_id, s.session_key, s.created, + l.password_hash, l.disabled, + u.user_id, u.first_name, u.last_name, u.city, u.wca_id, u.phone_number, u.gender, u.birth_date, u.approved, u.email +from session s +inner join login l on s.user_id = l.user_id +inner join user u on l.user_id = u.user_id; diff --git a/Database/TestData.sql b/Database/TestData.sql new file mode 100644 index 0000000..c46a609 --- /dev/null +++ b/Database/TestData.sql @@ -0,0 +1,71 @@ +use kh_sm; +set SQL_SAFE_UPDATES = 0; + +delete from attempt; +delete from result; +delete from meeting; +delete from discipline; +delete from counting; +delete from `user`; +delete from gender; + +-- gender + +insert into gender(gender) +values('male'); + +insert into gender(gender) +values('female'); + +-- counting + +SET @counting_avg5 = 'avg5'; +insert into counting(counting) +values(@counting_avg5); + +SET @counting_mo3 = 'mo3'; +insert into counting(counting) +values(@counting_mo3); + +SET @counting_bo3 = 'bo3'; +insert into counting(counting) +values(@counting_bo3); + +-- discipline + +insert into discipline(`name`, description, counting) values ('3x3', 'Rubiks Cube is a 3-D combination puzzle invented in 1974 by Hungarian sculptor and professor of architecture Ernő Rubik. Originally called the Magic Cube, the puzzle was licensed by Rubik to be sold by Ideal Toy Corp. in 1980 via businessman Tibor Laczi and Seven Towns founder Tom Kremer, and won the German Game of the Year special award for Best Puzzle that year. As of January 2009, 350 million cubes had been sold worldwide making it the worlds top-selling puzzle game. It is widely considered to be the worlds best-selling toy. On a classic Rubiks Cube, each of the six faces is covered by nine stickers, each of one of six solid colours: white, red, blue, orange, green, and yellow. In currently sold models, white is opposite yellow, blue is opposite green, and orange is opposite red, and the red, white and blue are arranged in that order in a clockwise arrangement. On early cubes, the position of the colours varied from cube to cube. An internal pivot mechanism enables each face to turn independently, thus mixing up the colours. For the puzzle to be solved, each face must be returned to have only one colour. Similar puzzles have now been produced with various numbers of sides, dimensions, and stickers, not all of them by Rubik. Although the Rubiks Cube reached its height of mainstream popularity in the 1980s, it is still widely known and used. Many speedcubers continue to practice it and other twisty puzzles and compete for the fastest times in various categories. Since 2003, The World Cube Association, the Rubiks Cubes international governing body, has organised competitions worldwide and kept the official world records.', @counting_avg5); +SET @discipline_id_3_3 = last_insert_id(); + +insert into discipline(`name`, description, counting) values ('2x2', 'The Pocket Cube (also known as the Mini Cube or the Ice Cube) is the 2×2×2 equivalent of a Rubiks Cube. The cube consists of 8 pieces, all corners.', @counting_avg5); +SET @discipline_id_2_2 = last_insert_id(); + +insert into discipline(`name`, description, counting) values ('Skewb', 'The Skewb (/ˈskjuːb/) is a combination puzzle and a mechanical puzzle in the style of Rubiks Cube. It was invented by Tony Durham and marketed by Uwe Mèffert. Although it is cubical in shape, it differs from Rubiks construction in that its axis of rotation pass through the corners of the cube rather than the centres of the faces. There are four such axes, one for each space diagonal of the cube. As a result, it is a deep-cut puzzle in which each twist affects all six faces. Mèfferts original name for this puzzle was the Pyraminx Cube, to emphasize that it was part of a series including his first tetrahedral puzzle Pyraminx. The catchier name Skewb was coined by Douglas Hofstadter in his Metamagical Themas column, and Mèffert liked it enough not only to market the Pyraminx Cube under this name but also to name some of his other puzzles after it, such as the Skewb Diamond. Higher order Skewbs, named Master Skewb and Elite Skewb, have also been made.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('3x3 One-Handed', '3x3 which must be solved by one hand only', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('4x4', 'The 4x4x4 Cube (also known as Rubiks Revenge, and normally referred to as the 4x4x4 or 4x4) is a twisty puzzle in the shape of a cube that is cut three times along each of three axes.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('5x5', 'The 5x5x5 cube (also known as the Professors Cube, and normally referred to as the 5x5x5 or 5x5) is a twistable puzzle in the shape of a cube that is cut four times along each of three axes.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('Pyraminx', 'The Pyraminx is a triangular pyramid-shaped (or tetrahedron) puzzle. The parts are arranged in a pyramidal pattern on each side of the puzzle. The layers can be rotated with respect to each vertex, and the individual tips can be rotated as well. It was designed by Uwe Mèffert in the early 1970s.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('Clock', 'Rubiks Clock (often shortened to Clock) is a round, 2-sided puzzle that consists of 18 mini "clocks", 9 on each side, arranged in 3x3 squares. The hands on the clock can be manipulated by turning small wheels on the side of the puzzle and by pressing four buttons on the face. The object is to get them all pointing in the 12:00 position. The only commonly used type of Clock is the Rubiks brand. Since that company no longer produces this puzzle, to get one it is usually necessary to use eBay to find one. Fortunately this is a very common and inexpensive puzzle.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('Square-1', 'The Square-1 (short for Back to Square One, also known as Cube 21) is a cubic twisty puzzle, with irregular cubies. It is one of the most popular shape-shifting Rubiks Cube variants.', @counting_avg5); + +insert into discipline(`name`, description, counting) values ('6x6', 'The 6x6x6 cube (normally referred to as the 6x6x6 or 6x6) is a twistable puzzle in the shape of a cube that is cut five times along each of three axes. The only available brand for this puzzle was the V-Cube, which was patented by Panagiotis Verdes.', @counting_mo3); + +insert into discipline(`name`, description, counting) values ('7x7', 'The 7x7x7 cube (normally referred to as the 7x7x7 or 7x7) is a twistable puzzle in the shape of a cube that is cut six times along each of three axes. The first brand of this puzzle was the V-Cube 7, which was patented by its inventor, Panagiotis Verdes.', @counting_mo3); + +insert into discipline(`name`, description, counting) values ('3x3 Blindfolded', 'Blindfolded solving (abbreviated BLD) is the discipline of memorizing the position a puzzle is in and then solving it without looking at it again.', @counting_bo3); + +insert into discipline(`name`, description, counting) values ('3x3 Fewest Moves', 'Fewest Moves (or Fewest Moves Challenge, FMC) is an event where competitors attempt to solve a puzzle (almost always the 3x3x3) in as few moves as possible, starting from a given scramble.', @counting_bo3); + +insert into discipline(`name`, description, counting) values ('Megaminx', 'The Megaminx is a puzzle in the shape of a dodecahedron that was first produced by Uwe Meffert, who has the rights to some of the patents. Each of the 12 sides consists of one pentagonal fixed center, five triangular edge pieces and five corner pieces.', @counting_avg5); + + + + + + + diff --git a/Documents/Database Scheme.jpg b/Documents/Database Scheme.jpg index bd22597..d678e05 100644 Binary files a/Documents/Database Scheme.jpg and b/Documents/Database Scheme.jpg differ diff --git a/Documents/Database Scheme.xml b/Documents/Database Scheme.xml index c60a0d9..4a763f4 100644 --- a/Documents/Database Scheme.xml +++ b/Documents/Database Scheme.xml @@ -1 +1 @@ -7V1dc5s4FP01ntl92eEb+7F2m+5Mkt1O0s4+ZhSj2Ewx8oDcJP31K4yEgQsN2ALLqdLMNAgQoHOPuPfoSkzsxeblc4K261sS4GhiGcHLxP44sSzTcCz2X1bympe4rpMXrJIw4AcdCu7Dn1icyUt3YYDTyoGUkIiG22rhksQxXtJKGUoS8lw97IlE1atu0QqDgvslimDpf2FA17zUNIzDjr9xuFrzS09dvuMRLb+vErKL+fUmlv20/8l3b5Coix+frlFAnktF9qeJvUgIoflfm5cFjrK2Fc2Wn3fVsre47wTHtMsJHKcfKNrxR/+W4oTfG30V7ZE+h5sIxWxr/kRies/3GGx7uQ6j4Aa9kl12wZSyBhBb8zVJwp/seBSxXSYrYLsTyuG2vKy2MIoWJCLJ/jo2NrJ/lTPvsxr5tRKcsnO/iKcza0W36KVy4A1KqbhLEkVom4aP+/vOTtygZBXGc0Ip2fCDxFNeVW+KA2jPURSuYla2ZNdijWTPYWtzAH7ghOKXUhFv/c+YbDBNXtkhfK8345bAmeKIKp5LdufxY9YlkysKEbf1VVH3AW/2B4e8GX4bwL9j8D+w3cAC1mib/ckeloYoumOsQ/Eq2zunZMsbMMJPosETfqfZ34/lRhaNmB87z1oqZMT7wIs3YRDsay1bRkz2ppdu0TKMVzf5VTzjUHTHr+awIsKqfIr2pFqzynCc3Q2hiKLHwmi3JIzpvt3cOftlLbkw/nInLnvSBds2D9vsNzs8oQsSpzRB4R5pzCzrGWfWNQ8Ssv3KbAmLpy0TxG00EquzkXCjYFTpZBO2BJNwgEl8ue5jDeXHN9uso2IRDUh3NZKqRTiyDOItzLPH5o8H0bVb0C3eVvyylR6/gnoJZtcbBmUfoPwUJil9iNEGn437+8oul/gSiO52JLroEE4xgSkwgZOBP5HWOf4qctpvAVIap2UAas4AohHSnD43p6fWeJwWYYUmdQdSF3xRmtVFSHGA9HmJ9i668Ue8i6I/NbfPxm3TmI5Ibhisa3K3kbs1EleL3C6AdBnSV83o8zHadkZktKcZ3Z3RbguWajEaRtY3ZBXGEFctqU4aJNV2qYcm5Dsu1WHsf5o9u86sFyrstKrCWo4BeoHCua6osKK7OMlmYCh+OTLsVNF3QQcZti2q76PDNlqFDIXOgtF8JsQurq6zZvl2bfaxjd9elC0oJuXlMR0Icxt6+FuUps8kCR7WKF2r7heq2hfI4H6DNNvIfRlOhA3H5bRf2Dre0ubjS6O2FEjhuFpB7RRFVFP7fNRuUGiHozaM+DW1W6nttECpFrVhFB+EaXZnl6PRvmd6N4m0w/Ebxv+a36389lqwVIvfHUbKcbDCIsBijxfS1zscIRqS+NNhz3xNN0K2wXHwIUtUZJuf7n7ihHwl/+a8zPScw64NioN8B2w+EQKSXbLk98EdRyo4NDmEPtkt/pIuZcXTaNI6RGGyf7QfuHIXTS3Mr/Els5iD2GKbVbHFqVeRPxA/64ATqMipVWTXK8qbAVS0B7x48G42AKPxe5ymDF/Ibi3sTRqEPW77kgQ7e/a2YNecNinYfFKOHBxmT3NruAzNTtXxm7c1u4KHauZOwrF6nTzZyyMomKV09qSY3jCWYG9q8h9H/q75lFKMAop2V1ql78d+qVLeYOyHUp54+3/HOndDWqTfn+7TEdOnHSjk9RyU+71ifUeqljdU+oYD5ZtlghHF53PrNa8t04QK/XDE1hMjerD6ImZGOFC9AZCepuAxGL+SWxS//kreKAt1QpUrK3XC/VFGqfP8qsri+UcqdX6tIrdekTylzoWx2S3GlNEEslgrdZPBlTrTPFapMyQodS4Myja5NWilbthgveChkkqdq6c5n/jmdy9inrML/XnB/3i3eWxa6kK79ce59f0JP+ZsZ1d79T24fRFevQu9+oBF6prR52P0mHOdRb2a0V0YfRFznT04fvoxTJfhNgrjBl7r8G348M03ahKA4XcM32TMjPJgMB8UBqEjuGEjOK+7sZwhgvNgZK8juF7vhIJcSkdwHsyu1gvayPTy+tN8zLjN0/nUPRgtNZ96MC8PhuIBTpdJuKX7fNoLmTLxnhk+ahzXYXBOM1xAKXUe81AMn0HfDFGKN1v6sCS7WPnJju+Z2qMuWTWDwyya223cng0+oiIDUh/Kbnc43TXNYNb6zGRwfcapyjOumAH29vLhEuQZH+p1yd4WLkOa8RXt5t+WZgoSKinN+FC109JMv/WGpa5e6A+FM0yBViC5pqOPpyr5ZZB9TIHGh/rcUUtU/V6+ni91McMmgktx36H4du6F6TS7O4szMrr4GdTqNLvfjuSk5s8M9fqeNeTPKDG4qjk+7oyXYrqAZnkvlkvNqRnqHW4aMEhH7JazL+BdylDLu2a6PaIeaxowNNcEb/1AiDF4GC4lAoNe+h2JdL7cWfTYaW1hIlfY0CjTnXzorifMFC5DjlV11K2DHCtjJfHh5NjmlcS1HNtdrRl81F0GzkIX0JlyQzl3MhYNH+57jjDpQodvvyB1QRelh9uL6dP1V7peauiszB4zQ840YQj/7VoHbq3UPpBGaW5PYXbUhzxNTsduCuTS2H5HZUZK6DaFg+kiZ1JHb4NGb9O2lcmUiN6mcHxdR2/9HL3BPxwoBWcYpZ8/l+7dOXr9uT5mCDdrGYfTfl4Lsy9iVnvhHpRgpeHmcobf3jO9R43jDD3Vqc/o20XMdTKNDh/rG3lxSbfhMzAi41CZxSVntZEyR1Cx7+KSXn0OhFmrSN7ikqbRYULTyGCLafxlsEVqijJg176vPDNrNXTF2nVry5HUK5KJdYevs4+MdQOvRa6hMlAXCzMJXh+7aKxdx9obEOsOywyMjLVotjLYU9U68VrXaxb19gXbrNdk1GuSiLYF4yzJaG9yoOHH3E6huq/al9xqmM0cSeBP6xXJxB6OpfyDn1OAv1bcGxX3fVvkT2M6hS03uOjAvNtl99ri4H73b7lJmMNqCu6X0yaYQWjVXabqXjYbq7PZKCTDm5Zeb6xXQN8Z8eZYvm4J1ptvuqGEedOCccCFzIJTuneQ3RuMKdSbFowXtFI/FPe9o7gvB+cG6R6/6AWMVCL+qBK+pSX8kVg/PR/rbagP5CvLGygOsqA81GnVSnUBoy5qZtpQQtB9wBB9gH3GYT0bygIA5G4aYb1NuivENaHvGM2wi/c0qEZo14YDPE+WSOjLEgnZZkIILR+eoO36lgQ4O+J/dZHBEoIgEIafhrtCk3U2q0snD51JUJiQdRBH6+nTkIyxuLB8/7+7sCCS1sPJ0EZcgHGFcMQGRA4I43iDt+M2kYcjCY4cqIxks2kBuXzyGXpbJxlvA6MFUFY2ISxAa17YgFFjoA9tJaiwa0MrvgJ5QdWaXiWzwtEdThZ+5rISvnO83TvlRot7ZaDTcz+ESfleTq6przU/tBWUQf+FSIZIagCsi+oh5WqarR+byzv+UT/3NlzbHwljsNQeD8EHkuwF \ No newline at end of file +7V1tc5u6Ev41nrnnyx3ebX+sfZpzZ9Kc00nauR8zilFsphh5QG6S/vojjISBhQZiASJR25kaAQK0+0i7j1armb3eP/8Vo8Puhvg4nFmG/zyz/5xZlmk4FvsvLXnJSlzXyQq2ceDzi84Fd8EvLO7kpcfAx0npQkpISINDuXBDoghvaKkMxTF5Kl/2SMLyUw9oi0HB3QaFsPT/gU93vNQ0jPOJ/+Fgu+OPXrj8xAPa/NjG5Bjx580s+/H0Jzu9R6Iufn2yQz55KhTZn2f2OiaEZr/2z2scpm0rmi2776rhbP7eMY5omxu4nH6i8Mg//XuCY/5u9EW0R/IU7EMUsaPVI4noHT9jsOPNLgj9L+iFHNMHJpQ1gDha7Ugc/GLXo5CdMlkBOx1TLm7LS2sLwnBNQhKfnmNjI/1buvMurZE/K8YJu/er+DqzUnSDnksXfkEJFW9JwhAdkuDh9N7pjXsUb4NoRSgle36R+Mqr8ktxAdorFAbbiJVt2LNYI9kr2NpcAD9xTPFzoYi3/l+Y7DGNX9gl/Ky35JrAkeKIKp4Keufxa3YFlbPEjYjr+jav+yxv9oOLvF78NhD/kYn/np0GGrBDh/Qn+1gaoPCWoQ5F2/TsipIDb8AQP4oGj/mbpr8fio0sGjG7dpW2VMCA94kX7wPfP9Va1IyInFQvOaBNEG2/ZE/xjHPRLX+aw4oIq/IxPIFqxyrDUfo2hCKKHnKlPZAgoqd2c1fsH2vJtfFfd+ayL12zY/N8zP6ll8d0TaKExig4SRozzXrCqXat/JgcvjFdwuJriwBxa5XEaq0kXCkYVFrphC1BJRygEl+vu2hD8fPNJu0oaUSNpNsqSVkjHFkK8ZrM08/mnwelazdINx+t+GNLPX5J6gUxu14/Up4DKT8GcULvI7THo2H/VNl0gS8B6G5LoIsO4RIVWAAVuFjwF8I6k7+KmJ43CFIapmUI1FwCiYZIY3psTC+s4TAt3AoN6hagzvGiNKpzl+Is0qcNOpnoxn+iYxj+obE9GrZNYzEguKGzrsHdBO5GT1wtcLtApJuAvmhEj4do2xkQ0Z5GdHtEuw2yVArRpg05tcOONf19dNw/1FGrGtpDQVvAeAho5/2IxnYbU9zunTWTI1Q4Xm9x5KewZtXbZqaphZ8a7GOBfTEglWbaeiDvAvaJjOSQJH8IYrq79xHVhNp40LbMARk109Y8eRdoT4Mpd6FxhvcoCDWqx0O1PSCVZrrQkNOobkR1DhfFUQ2tMHQ4xKy1fG2UK4Fxb0ByzXSh/aYx3oxxr0GaSmG8JnDlC9kGERSsjlic1UQsNkdS0Zj8wIU6jNOfel1pjXsR5LgoBzlaDmTi8rnrosrk82sX6Qy04KcT5bhQdDRoEeXY5Ap0CXOs1QoZAXAWDJZJ4xzXV9dps3y/NrvoxoePecwhJmXwWPQkc9sCMj+gJHkisX+/Q8lOdctQ1b5ABvZrIh9rsS/DiKiZotOGYWM4c5OVLw3aUkQKXT8/SNI3Y4P8ROKk3jO8a4Ig+4O39vs6wFuq29cbvFuQ8NjfYmGEsc8L6MstDhENSPT5fGa1o3vh2uHI/5SuFWSHn29/4Zh8I/9ksEx9vvOpPYr87ESjK5SQY7zh78EHFyogVDCP0lf8LVqKvIhR6w/xwvj0aT9x6S3qWpg/42uqMWeHzDbLDplTrSL7IH7XWU6gIqdSkV2tKGsGUNFJ4PmHt9MBaLHf4SRh8oXo1s5/nfPPdV+SU28vX3fqa8m9fErtomVqMNI9ybRhGn69qizv6359jkM1ly/CcHm9frGTRZAjS+kFjCLDwFCknqnB/zbwt13SKEUpoGN/pZm8buiX6u73hn4YvyFG/x9YL5+Q5uh3h/uQYZcODOLoSNx/LF/fmUQYhwPpm02MEcXjmfUa15Y5ZNClo2MuO6B6EiGXDmRvgEgvY/CYGL+RGxS9/I7eKBJ1gpUrMnXC/FGGqfPmZZbFm7+RqZtXKnKrFclj6lzom91gTBlMIIo1UzfrnakzzbcydTLCb1zolO0zbdBMXb/Oeo5DJZm6mhh8zdR1GvndSaQaqwnDFfjXS6LlmvXdAT9kwjFXW/UdsD0Jq96FVr1eHDkuoodMNybq1Yhug+hJpBvz4Pzpn0GyCQ5hENXgWrtv/btvOcwEBeC0HLTzHCQXKQR05v1cIbQH168H57VXlhE8OA969tqD6zQm5OBS2oPzYHC1zikr08rrDvMh/TZPx1N3QPQkltF60BX3cbKJgwM9xdNOZMXEe0b4oH5ci8k5jXAhSqlrHftC+BLaZhtyjOon4TSqh0L1oAmjl3CGpWM45MdC9nISSSjnkHS7xckxpFCwmp2Z9c7OOGVyxhXrv14lZ2Ts3zWHbF180oVpEDNzRXv614mZHIRKEjNzyNlpYqbbhj9Stw+Y9yVnGACtQGhNSzNPVfDLAPuQ9MwcsnNvSmLzsWy9udQcxHUAl2LBQ+pt7NRVGt2tqRkZXfwSMnVX15ZG9yuenNTomb6G72VN9IwSU6sa48Oud8kXC5RQbmuUv4JyqRE1fY3hpgGddMReOd2CfioTLe8a6YMmHjega64B3piU2OjdDZeTaRp234hSvD/Q+9Oki4b3iPAeNuc47Os1vJtzjktNNdMXvOfQCb8loQ6GHWW6ZVHJOuaKIWKQtYxz6I3HTBWmMdui6rx6i9kWGanE+5ttqU8lrmdb2pOxvYfUyJCzoP10GGxfxp2MrOG92XYLGFGl2ZnfgDqHi9LRNHluhOqQrvOIjYrsIcNfTRN6bd+vtePWCO0zaJTG9gLGP37KaBntuykQKmfPWxKvUly3BYyVERyd9t569d4WTWkHlfDeFjB8Rntv3Qy93rfzliJn6KWPHyr77gy97lgf0oVbQltfL4j4HbInkbIiNw8KYqXBfjqz6+8Z3oP6cYZex9hlcn0SCxlNo8VufQNnjnVr9ngSAcXKZI5dVmbKHAHFrpljveoSJ7NSkbzMsaYBXfaxhS1ydBSFLSLPlBF2ZYPlpVmpoa2sXbdckVetSKasoec1tqxrcC1CiZURdTUdlPPWjNB2VdZej7JukUNkYFmLZisKe6FaJ17pes283q7CNqs1GdWaJErbgn6WZGnvM0HDnRovgfpctW0aKzJbOpKEv6hWJFP2cC7lb/yUAPlrxr2WcT+1RfY1piOJdgeZ/7n1Mkj+QFNgvxg2wRRCs+4yWfei2lj1atNBcUag4U1LJxPs5NC3kbjVIHGliHnTgn7ARBa5Kt07yO4NhiTqTQv6C5qp74T9SeQhNK0a6h4/K79g5kMBf1AK39IU/iWonwalb0N+INs2wkCRnzrlgQ6rVqoLGDRtoWnrRXQX9AE5uBTvAyAtAITcjiOstkmfswG5XaUMR2hXd4fwZJGE8/5IQgcOAOvG7LSaKJz1Hpqbp7LNbb6WaSxdGX2BAzv80ZMVvz+OsEZNnKaxQg1OUOzGqznBNwf5OE1dgVocoLztoXuzBvKmLEUH2IqZA1ZlUn/5VnOgGh2wqFb0ZnOAHcaE0OLlMTrsboiP0yv+BQ== \ No newline at end of file diff --git a/Documents/Rest Api documentation.md b/Documents/Rest Api documentation.md index 541032b..1824788 100644 --- a/Documents/Rest Api documentation.md +++ b/Documents/Rest Api documentation.md @@ -25,25 +25,49 @@ Response: [Session](#session) ## Get user GET users/{id} +GET users/me + Response: [User](#user) ## Update user PUT users/{id} +PUT users/me + Request: [User](#user) Response: [User](#user) +## Change password +PUT users/{id}/password + +PUT users/me/password + +Request: [ChangePasswordRequest](#changepasswordrequest) + ## Get user records GET users/{id}/records +GET users/me/records + Response: Array<[DisciplineRecord](#disciplinerecord)> ## Get user results GET users/{id}/results +GET users/me/results + Response: Array<[DisciplineResults](#disciplineresults)> +## Insert user result +POST users/{id}/results + +POST users/me/results + +Request: [Result](#result) + +Response: [Result](#result) + ## Get meetings GET meetings @@ -59,6 +83,11 @@ GET meetings/{id}/results Response: Array\<[DisciplineResults](#disciplineresults)\> +## Get last meeting +GET meetings/last + +Response: [Meeting](#meeting) + ## Get disciplines GET disciplines @@ -74,6 +103,11 @@ Query: Response: Array\<[DisciplineResults](#disciplineresults)\> +## Get news +GET news + +Responce: Array\<[News](#news)\> + # Entities ## CreateSessionRequest @@ -105,6 +139,12 @@ Response: Array\<[DisciplineResults](#disciplineresults)\> - birth_date: Date - phoneNumber: String +## News +- id: Integer +- user: [User](#user) +- text: String +- date: Date + ## String\ - "female" - "male" @@ -118,7 +158,7 @@ Response: Array\<[DisciplineResults](#disciplineresults)\> - id: Integer - name: String - description: String -- attempsCount: Integer +- attemptsCount: Integer ## DisciplineResults - discipline: [Discipline](#discipline) @@ -126,8 +166,8 @@ Response: Array\<[DisciplineResults](#disciplineresults)\> ## DisciplineRecord - discipline: [Discipline](#discipline) -- bestTime: [Result](#result) -- bestOverageTime: [Result](#result) +- bestSingleResult: [Result](#result) +- bestAverageResult: [Result](#result) ## Result - id: Integer @@ -135,3 +175,6 @@ Response: Array\<[DisciplineResults](#disciplineresults)\> - user: [User](#user) - average: Nullable\ - attempts: Array\\> + +## ChangePasswordRequest +- password: String diff --git a/README.md b/README.md index 390a9f5..e83864e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # KhSM +develop: + +[![Build Status](https://travis-ci.org/maximzhemerenko/KhSM.svg?branch=develop)](https://travis-ci.org/maximzhemerenko/KhSM) + +master: + +[![Build Status](https://travis-ci.org/maximzhemerenko/KhSM.svg?branch=master)](https://travis-ci.org/maximzhemerenko/KhSM) + **Resources** https://www.draw.io/ diff --git a/Test/Backend/.gitignore b/Test/Backend/.gitignore new file mode 100644 index 0000000..cd62937 --- /dev/null +++ b/Test/Backend/.gitignore @@ -0,0 +1,3 @@ +obj +bin +*.user diff --git a/Test/Backend/Managers/ResultsManager.cs b/Test/Backend/Managers/ResultsManager.cs new file mode 100644 index 0000000..2d3c47f --- /dev/null +++ b/Test/Backend/Managers/ResultsManager.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Backend; +using Backend.Data.Entities; +using Backend.Domain; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace TestProject.Managers +{ + public class ResultsManager + { + private readonly DisciplinesManager _disciplinesManager; + private readonly MeetingsManager _meetingsManager; + private readonly Backend.Domain.ResultsManager _resultsManager; + private readonly UsersManager _usersManager; + + public ResultsManager() + { + var services = new ServiceCollection(); + Startup.ConfigDataServices(services); + + var serviceProvider = services.BuildServiceProvider(); + + _disciplinesManager = serviceProvider.GetService(); + _meetingsManager = serviceProvider.GetService(); + _resultsManager = serviceProvider.GetService(); + _usersManager = serviceProvider.GetService(); + } + + [Fact] + public void AddMeeting() + { + var meeting = new Meeting + { + Date = DateTime.Now + }; + + _meetingsManager.AddMeeting(meeting); + + Assert.True(meeting.Id > 0); + } + + [Fact] + public void AddResult() + { + Random random = new Random(); + + var meeting = new Meeting {Date = DateTime.Now}; + _meetingsManager.AddMeeting(meeting); + Assert.NotNull(meeting); + + var discipline = _disciplinesManager.GetDisciplinesAsync().Single(d => d.Name == "3x3"); + + var maxim = _usersManager.GetUsers(false).Single(u => u.FirstName == "Aleksandr" && u.LastName == "Omelchenko"); + + Result result; + + // add valid + result = new Result + { + Attempts = Attempts(random.Next(1, 100), random.Next(1, 100), random.Next(1, 100), random.Next(1, 100), random.Next(1, 100)), + Discipline = discipline, + User = maxim + }; + + TestAddResult(meeting, result); + + // 2x2 + discipline = _disciplinesManager.GetDisciplinesAsync().Single(d => d.Name == "2x2"); + + // add valid + result = new Result + { + Attempts = Attempts(random.Next(1, 100), random.Next(1, 100), random.Next(1, 100)), + Discipline = discipline, + User = maxim + }; + + TestAddResult(meeting, result); + } + + private void TestAddResult(Meeting meeting, Result result) + { + result.Meeting = meeting; + + _resultsManager.AddResult(result); + + Assert.True(result.Id > 0); + } + + private static IEnumerable Attempts(params decimal?[] attempts) => attempts.ToArray(); + } +} \ No newline at end of file diff --git a/Test/Backend/TestProject.csproj b/Test/Backend/TestProject.csproj new file mode 100644 index 0000000..20b4f79 --- /dev/null +++ b/Test/Backend/TestProject.csproj @@ -0,0 +1,15 @@ + + + netcoreapp2.0 + false + + + + + + + + + + + \ No newline at end of file