diff --git a/app/build.gradle b/app/build.gradle index 071bc34..40e8bf9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,8 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' + apply plugin: 'io.gitlab.arturbosch.detekt' android { @@ -23,17 +25,57 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' + } } dependencies { - detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.2.2" + // standard implementation project(':wrapper') + detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.2.2" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.core:core-ktx:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'com.google.android.material:material:1.0.0' + // wrapper + implementation project(':wrapper') + // retrofit + implementation "com.squareup.retrofit2:retrofit:2.6.2" + implementation "com.squareup.retrofit2:converter-gson:2.6.2" + // room database + implementation "androidx.room:room-runtime:$rootProject.roomVersion" + implementation "androidx.room:room-ktx:$rootProject.roomVersion" + kapt "androidx.room:room-compiler:$rootProject.roomVersion" + // lifecycle components + implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion" + //noinspection LifecycleAnnotationProcessorWithJava8 + kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion" + // ViewModel Kotlin support + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion" + // Coroutines + api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines" + // UI + implementation "com.google.android.material:material:$rootProject.materialVersion" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + + + // TESTING testImplementation 'junit:junit:4.12' + testImplementation "io.mockk:mockk:1.9.3" + testImplementation "com.squareup.okhttp3:mockwebserver:4.2.1" androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' + + implementation "com.squareup.retrofit2:retrofit:2.6.2" + implementation "com.squareup.retrofit2:converter-gson:2.6.2" + + androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion" + androidTestImplementation "androidx.arch.core:core-testing:$rootProject.androidxArchVersion" + androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion" } diff --git a/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/TestHelper.kt b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/TestHelper.kt new file mode 100644 index 0000000..017874f --- /dev/null +++ b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/TestHelper.kt @@ -0,0 +1,22 @@ +package it.unisannio.ding.ids.wedroid.app + +import androidx.lifecycle.* + +class OneTimeObserver(private val handler: (T) -> Unit) : Observer, LifecycleOwner { + private val lifecycle = LifecycleRegistry(this) + init { + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + override fun getLifecycle(): Lifecycle = lifecycle + + override fun onChanged(t: T) { + handler(t) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } +} + +fun LiveData.observeOnce(onChangeHandler: (T) -> Unit) { + val observer = OneTimeObserver(handler = onChangeHandler) + observe(observer, observer) +} diff --git a/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDaoTest.kt b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDaoTest.kt new file mode 100644 index 0000000..e6151bb --- /dev/null +++ b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDaoTest.kt @@ -0,0 +1,110 @@ +package it.unisannio.ding.ids.wedroid.app.data.dao + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import it.unisannio.ding.ids.wedroid.app.data.database.BoardDatabase +import it.unisannio.ding.ids.wedroid.app.data.entity.Board +import it.unisannio.ding.ids.wedroid.app.observeOnce +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class BoardDaoTest { + private lateinit var dao: BoardDao + private lateinit var db: BoardDatabase + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder( + context, BoardDatabase::class.java + ).build() + dao = db.boardDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun emptyDatabaseOnCreation() { + dao.getAllBoard().observeOnce { + assertEquals(0, it.size) + } + } + + @Test + fun insert() { + val board = Board("id", "title") + + runBlocking { + dao.insert(board) + } + + dao.getAllBoard().observeOnce { + assertEquals(1, it.size) + assertEquals(board, it[0]) + } + } + + @Test + fun replaceOnConflict() { + val board0 = Board("id", "title0") + val board1 = Board("id", "title1") + + runBlocking { + dao.insert(board0) + dao.insert(board1) + } + + dao.getAllBoard().observeOnce { + assertEquals(1, it.size) + assertEquals("title1", it[0].title) + } + } + + @Test + fun getInAscendingOrder() { + val board0 = Board("id0", "title0") + val board1 = Board("id1", "title1") + + runBlocking { + dao.insert(board1) + dao.insert(board0) + } + + dao.getAllBoard().observeOnce { + assertEquals(2, it.size) + assertEquals(board0, it[0]) + assertEquals(board1, it[1]) + } + } + + @Test + fun delete() { + val board = Board("id", "title") + + runBlocking { + dao.insert(board) + dao.delete(board) + } + + dao.getAllBoard().observeOnce { + assertEquals(0, it.size) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivityTest.kt b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivityTest.kt new file mode 100644 index 0000000..6fe7e0a --- /dev/null +++ b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivityTest.kt @@ -0,0 +1,52 @@ +package it.unisannio.ding.ids.wedroid.app.view + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import it.unisannio.ding.ids.wedroid.app.R +import it.unisannio.ding.ids.wedroid.app.view.adapter.BoardsListAdapter +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class BoardsListsActivityTest { + + @get:Rule + val activityRule = ActivityTestRule(BoardsListsActivity::class.java) + + fun swipeLeftToDelete() { + onView(withId(R.id.boardList)) + .perform( + RecyclerViewActions.actionOnItemAtPosition( + 0, swipeLeft() + ) + ) + } + + fun pullToRefresh() { + onView(withId(R.id.pullToRefresh)) + .perform(swipeDown()) + + } + + @Test + fun openNewBoardActivity() { + onView(withId(R.id.fab)) + .perform(click()) + + onView(withId(R.id.newBoardName)) + .check(matches(isDisplayed())) + } + +} + diff --git a/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivityTest.kt b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivityTest.kt new file mode 100644 index 0000000..62af7b7 --- /dev/null +++ b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivityTest.kt @@ -0,0 +1,87 @@ +package it.unisannio.ding.ids.wedroid.app.view + +import androidx.test.espresso.Espresso.closeSoftKeyboard +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.withDecorView +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import it.unisannio.ding.ids.wedroid.app.R +import org.hamcrest.CoreMatchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class LoginActivityTest { + @get:Rule + val activityRule = ActivityTestRule(LoginActivity::class.java) + + @Test + fun loginWithEmptyUrl() { + onView(withId(R.id.username)) + .perform(typeText("username")) + onView(withId(R.id.password)) + .perform(typeText("password")) + closeSoftKeyboard() + onView(withId(R.id.button)) + .perform(click()) + + onView(withText(R.string.login_empty_field)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + } + + @Test + fun loginWithEmptyUsername() { + onView(withId(R.id.instanceServer)) + .perform(typeText("https://wekan.com")) + onView(withId(R.id.password)) + .perform(typeText("password")) + closeSoftKeyboard() + onView(withId(R.id.button)) + .perform(click()) + + onView(withText(R.string.login_empty_field)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + + } + + @Test + fun loginWithEmptyPassword() { + onView(withId(R.id.instanceServer)) + .perform(typeText("https://wekan.com")) + onView(withId(R.id.username)) + .perform(typeText("username")) + closeSoftKeyboard() + onView(withId(R.id.button)) + .perform(click()) + + onView(withText(R.string.login_empty_field)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + } + + @Test + fun loginWithUnformedInstance(){ + onView(withId(R.id.instanceServer)) + .perform(typeText("not an URL")) + onView(withId(R.id.username)) + .perform(typeText("username")) + onView(withId(R.id.password)) + .perform(typeText("password")) + closeSoftKeyboard() + onView(withId(R.id.button)) + .perform(click()) + + onView(withText(R.string.login_unformed_instance)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivityTest.kt b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivityTest.kt new file mode 100644 index 0000000..4f49761 --- /dev/null +++ b/app/src/androidTest/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivityTest.kt @@ -0,0 +1,37 @@ +package it.unisannio.ding.ids.wedroid.app.view + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.withDecorView +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import it.unisannio.ding.ids.wedroid.app.R +import org.hamcrest.core.IsNot.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class NewBoardActivityTest { + + @get:Rule + val activityRule = ActivityTestRule(NewBoardActivity::class.java) + + @Test + fun showToastOnEmptyName() { + onView(withId(R.id.newBoardDone)) + .perform(click()) + + onView(withText(R.string.on_add_new_board_empty_name)) + .inRoot(withDecorView(not(activityRule.activity.window.decorView))) + .check(matches(isDisplayed())) + } + +} + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 253fb67..298ca17 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - + + + + + + diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/MainActivity.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/MainActivity.kt deleted file mode 100644 index e5390c0..0000000 --- a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/MainActivity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package it.unisannio.ding.ids.wedroid.app - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import it.unisannio.ding.ids.wedroid.wrapper.api.BoardService - -class MainActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - val service : BoardService? = null - } -} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDao.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDao.kt new file mode 100644 index 0000000..adcfa85 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/dao/BoardDao.kt @@ -0,0 +1,21 @@ +package it.unisannio.ding.ids.wedroid.app.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import it.unisannio.ding.ids.wedroid.app.data.entity.Board + +@Dao +interface BoardDao { + @Query("SELECT * from board_table ORDER BY title ASC") + fun getAllBoard(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(board: Board) + + @Delete + suspend fun delete(board: Board) +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/database/BoardDatabase.java b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/database/BoardDatabase.java new file mode 100644 index 0000000..07181ba --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/database/BoardDatabase.java @@ -0,0 +1,30 @@ +package it.unisannio.ding.ids.wedroid.app.data.database; + +import android.content.Context; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import it.unisannio.ding.ids.wedroid.app.data.dao.BoardDao; +import it.unisannio.ding.ids.wedroid.app.data.entity.Board; + +@Database(entities = Board.class, version = 1, exportSchema = false) +public abstract class BoardDatabase extends RoomDatabase { + private static volatile BoardDatabase INSTANCE; + public abstract BoardDao boardDao(); + + public static BoardDatabase getDatabase(Context context) { + if (INSTANCE != null) + return INSTANCE; + synchronized (BoardDatabase.class) { + INSTANCE = Room.databaseBuilder( + context.getApplicationContext(), + BoardDatabase.class, + "board_database" + ).build(); + + return INSTANCE; + } + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/entity/Board.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/entity/Board.kt new file mode 100644 index 0000000..eb61071 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/entity/Board.kt @@ -0,0 +1,15 @@ +package it.unisannio.ding.ids.wedroid.app.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "board_table") +data class Board( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "title") val title: String = "" +) + +fun it.unisannio.ding.ids.wedroid.wrapper.entity.Board.convert(): Board { + return Board(this.id, this.title) +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/repository/BoardRepository.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/repository/BoardRepository.kt new file mode 100644 index 0000000..1218371 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/data/repository/BoardRepository.kt @@ -0,0 +1,161 @@ +package it.unisannio.ding.ids.wedroid.app.data.repository + +import android.util.Log +import it.unisannio.ding.ids.wedroid.app.data.dao.BoardDao +import it.unisannio.ding.ids.wedroid.app.data.entity.Board +import it.unisannio.ding.ids.wedroid.app.data.entity.convert +import it.unisannio.ding.ids.wedroid.app.util.PreferenceReader +import it.unisannio.ding.ids.wedroid.wrapper.api.BoardService +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardBackgroundColor +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardPermission +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardPrototype +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class BoardRepository( + private val dao: BoardDao, + private val service: BoardService, + private val reader: PreferenceReader +) { + val allBoards by lazy { + dao.getAllBoard() + } + + init { + synchronize() + } + + fun synchronize() { + service.getBoardsFromUser(reader.userId) + .enqueue(object : + Callback> { + override fun onFailure( + call: Call>, + t: Throwable + ) = logNetworkError(t.message) + + override fun onResponse( + call: Call>, + response: Response> + ) { + CoroutineScope(Dispatchers.IO).launch { + synchronizeCallback(response) + } + } + }) + } + + private suspend fun synchronizeCallback( + response: Response> + ) { + if (!response.isSuccessful) { + logNetworkError("${response.code()} ${response.message()}") + return + } + + // read boards from the body + val boards = (response.body() ?: return) + .map { it.convert() } + + addNewBoardToDb(boards) + + removeOldBoardsFromDb(boards) + } + + fun insertBoard(title: String, isPrivate: Boolean, color: BoardBackgroundColor) { + val permission = if (isPrivate) BoardPermission.PRIVATE else BoardPermission.PUBLIC + + service.newBoard( + BoardPrototype.Builder() + .setOwner(reader.userId) + .setTitle(title) + .setBackgroundColor(color) + .setBoardPermission(permission) + .build() + ).enqueue(object : Callback { + override fun onFailure( + call: Call, + t: Throwable + ) = logNetworkError(t.message) + + override fun onResponse( + call: Call, + response: Response + ) { + CoroutineScope(Dispatchers.IO).launch { + insertBoardCallback(response, title) + } + } + }) + } + + private suspend fun insertBoardCallback( + response: Response, + title: String + ) { + if (!response.isSuccessful) { + logNetworkError("${response.code()} ${response.message()}") + return + } + + val board = response.body() + + if (board == null) { + logNetworkError("empty body") + return + } + + dao.insert(Board(board.id, title)) + } + + fun deleteBoard(id: String) { + service.deleteBoard(id).enqueue( + object : Callback { + override fun onFailure(call: Call, t: Throwable) { + logNetworkError(t.message) + } + + override fun onResponse( + call: Call, + response: Response + ) { + CoroutineScope(Dispatchers.IO).launch { + deleteBoardCallback(response, id) + } + } + }) + } + + private suspend fun deleteBoardCallback( + response: Response, + id: String + ) { + if (!response.isSuccessful) { + logNetworkError("${response.code()} ${response.message()}") + return + } + + dao.delete(Board(id)) + } + + private suspend fun addNewBoardToDb(boards: Collection) { + boards.forEach { + dao.insert(it) + } + } + + private suspend fun removeOldBoardsFromDb(boards: Collection) { + allBoards.value?.minus(boards) + ?.forEach { + dao.delete(it) + } + } + + private fun logNetworkError(message: String?) { + Log.e("RETROFIT", message) + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceReader.java b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceReader.java new file mode 100644 index 0000000..3fe9de0 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceReader.java @@ -0,0 +1,7 @@ +package it.unisannio.ding.ids.wedroid.app.util; + +public interface PreferenceReader { + String getBaseUrl(); + String getUserId(); + String getToken(); +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceWriter.java b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceWriter.java new file mode 100644 index 0000000..264b507 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/PreferenceWriter.java @@ -0,0 +1,7 @@ +package it.unisannio.ding.ids.wedroid.app.util; + +public interface PreferenceWriter { + void setBaseUrl(String baseUrl); + void setUserId(String userId); + void setToken(String token); +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/ServicesFactory.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/ServicesFactory.kt new file mode 100644 index 0000000..eece43a --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/ServicesFactory.kt @@ -0,0 +1,83 @@ +package it.unisannio.ding.ids.wedroid.app.util + +import it.unisannio.ding.ids.wedroid.wrapper.api.BoardService +import it.unisannio.ding.ids.wedroid.wrapper.api.CardCommentService +import it.unisannio.ding.ids.wedroid.wrapper.api.CardService +import it.unisannio.ding.ids.wedroid.wrapper.api.ChecklistService +import it.unisannio.ding.ids.wedroid.wrapper.api.ListService +import it.unisannio.ding.ids.wedroid.wrapper.api.SwimlanesService +import it.unisannio.ding.ids.wedroid.wrapper.api.UserService +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class ServicesFactory private constructor( + reader: PreferenceReader +) { + private val retrofit: Retrofit + + init { + val httpClient = OkHttpClient.Builder() + .addInterceptor { + val request = it.request().newBuilder() + .addHeader("Authorization", "Bearer ${reader.token}") + .addHeader("Accept", "application/json") + .addHeader("Content-type", "application/json") + .build() + + it.proceed(request) + }.build() + + retrofit = Retrofit.Builder() + .baseUrl(reader.baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(httpClient) + .build() + } + + val boardService by lazy { + retrofit.create(BoardService::class.java) + } + + val cardCommentService by lazy { + retrofit.create(CardCommentService::class.java) + } + + val cardService by lazy { + retrofit.create(CardService::class.java) + } + + val checklistService by lazy { + retrofit.create(ChecklistService::class.java) + } + + val listService by lazy { + retrofit.create(ListService::class.java) + } + + val swimlanesService by lazy { + retrofit.create(SwimlanesService::class.java) + } + + val userService by lazy { + retrofit.create(UserService::class.java) + } + + companion object { + @Volatile + private var INSTANCE: ServicesFactory? = null + + fun getInstance(reader: PreferenceReader): ServicesFactory { + val tempInstance = INSTANCE + + if (tempInstance != null) + return tempInstance + + synchronized(this) { + val instance = ServicesFactory(reader) + INSTANCE = instance + return instance + } + } + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/SharedPreferenceHelper.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/SharedPreferenceHelper.kt new file mode 100644 index 0000000..9f4f37d --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/util/SharedPreferenceHelper.kt @@ -0,0 +1,34 @@ +package it.unisannio.ding.ids.wedroid.app.util + +import android.content.Context + +class SharedPreferenceHelper(context: Context) : PreferenceReader, PreferenceWriter { + private val sp = context.getSharedPreferences("userinfo", Context.MODE_PRIVATE) + + override fun getBaseUrl(): String? { + return sp.getString("url", "") + } + + override fun getUserId(): String? { + return sp.getString("id", "") + } + + override fun getToken(): String? { + return sp.getString("token", "") + } + + override fun setBaseUrl(baseUrl: String?) { + val editor = sp.edit() + editor.putString("url", baseUrl).apply() + } + + override fun setUserId(userId: String?) { + val editor = sp.edit() + editor.putString("id", userId).apply() + } + + override fun setToken(token: String?) { + val editor = sp.edit() + editor.putString("token", token).apply() + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivity.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivity.kt new file mode 100644 index 0000000..d3c6924 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/BoardsListsActivity.kt @@ -0,0 +1,133 @@ +package it.unisannio.ding.ids.wedroid.app.view + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import it.unisannio.ding.ids.wedroid.app.R +import it.unisannio.ding.ids.wedroid.app.util.PreferenceReader +import it.unisannio.ding.ids.wedroid.app.util.SharedPreferenceHelper +import it.unisannio.ding.ids.wedroid.app.view.adapter.BoardsListAdapter +import it.unisannio.ding.ids.wedroid.app.viewmodel.BoardsListViewModel +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardBackgroundColor + +import kotlinx.android.synthetic.main.activity_boards_lists.* +import kotlinx.android.synthetic.main.content_boards_lists.* +import java.util.Locale + +class BoardsListsActivity : AppCompatActivity() { + private lateinit var viewModel: BoardsListViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_boards_lists) + setSupportActionBar(toolbar) + val reader: PreferenceReader = SharedPreferenceHelper(this) + + if (reader.token == "") + startActivityForResult( + Intent(this, LoginActivity::class.java), + LOGIN_CODE + ) + else initializeUi() + } + + private fun initializeUi() { + viewModel = ViewModelProvider(this).get(BoardsListViewModel::class.java) + + val adapter = BoardsListAdapter(this) + boardList.adapter = adapter + boardList.layoutManager = LinearLayoutManager(this) + + viewModel.allBoards.observe(this, Observer { + it.let { adapter.setBoards(it) } + pullToRefresh.isRefreshing = false + }) + + swipeLeftToDelete() + + fab.setOnClickListener { + startActivityForResult( + Intent(this, NewBoardActivity::class.java), + NEW_BOARD_CODE + ) + } + + pullToRefresh.setColorSchemeColors(getColor(R.color.colorAccent)) + pullToRefresh.setOnRefreshListener { + viewModel.refresh() + } + } + + private fun swipeLeftToDelete() { + val swipeToDelete = ItemTouchHelper( + object : ItemTouchHelper.SimpleCallback( + 0, ItemTouchHelper.LEFT + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val pos = viewHolder.adapterPosition + viewModel.deleteBoard(pos) + } + }) + + swipeToDelete.attachToRecyclerView(boardList) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + LOGIN_CODE -> onLoginResult(resultCode) + NEW_BOARD_CODE -> if (data != null) onAddBoardResult(resultCode, data) + else -> finish() + } + } + + private fun onLoginResult(resultCode: Int) { + when (resultCode) { + LoginActivity.LOGIN_OK -> initializeUi() + LoginActivity.LOGIN_ERROR -> finish() + else -> finish() + } + } + + private fun onAddBoardResult(resultCode: Int, data: Intent) { + if (resultCode != NewBoardActivity.RESULT_OK) { + Toast.makeText(this, R.string.on_add_new_board_error, Toast.LENGTH_LONG) + .show() + return + } + + val title = data.getStringExtra(NewBoardActivity.BOARD_NAME) + if (title == null) { + Toast.makeText(this, R.string.on_null_new_board_name, Toast.LENGTH_LONG) + .show() + return + } + + val isPrivate = data.getBooleanExtra(NewBoardActivity.BOARD_PRIVATE, true) + + val colorName = data.getStringExtra(NewBoardActivity.BOARD_BACKGROUND_COLOR) ?: "belize" + val backgroundColor = BoardBackgroundColor + .valueOf(colorName.toUpperCase(Locale.ROOT)) + + viewModel.insertBoard(title, isPrivate, backgroundColor) + } + + companion object { + const val NEW_BOARD_CODE = 17 + const val LOGIN_CODE = 19 + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivity.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivity.kt new file mode 100644 index 0000000..811db1a --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/LoginActivity.kt @@ -0,0 +1,97 @@ +package it.unisannio.ding.ids.wedroid.app.view + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.view.View +import android.webkit.URLUtil +import android.widget.Toast +import it.unisannio.ding.ids.wedroid.app.R +import it.unisannio.ding.ids.wedroid.app.util.SharedPreferenceHelper +import it.unisannio.ding.ids.wedroid.wrapper.api.LoginService +import it.unisannio.ding.ids.wedroid.wrapper.entity.UserPrototype +import kotlinx.android.synthetic.main.activity_login.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class LoginActivity : AppCompatActivity() { + lateinit var sph: SharedPreferenceHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + sph = SharedPreferenceHelper(this) + + setResult(LOGIN_ERROR) + + val id = sph.userId + val token = sph.token + val url = sph.baseUrl + } + + fun loginButton(v: View) { + if (username.text.isBlank() || instanceServer.text.isBlank() || password.text.isBlank()) { + Toast.makeText(this, R.string.login_empty_field, Toast.LENGTH_LONG) + .show() + return + } + + val userNameText = username.text.toString() + val passwordText = password.text.toString() + val instanceServerText = instanceServer.text.toString() + + if (!URLUtil.isValidUrl(instanceServerText)){ + Toast.makeText(this, R.string.login_unformed_instance, Toast.LENGTH_LONG) + .show() + return + } + + val service = Retrofit.Builder() + .addConverterFactory(GsonConverterFactory.create()) + .baseUrl(instanceServerText) + .build() + .create(LoginService::class.java) + + service.login(userNameText, passwordText).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + applicationContext, + R.string.login_network_error, + Toast.LENGTH_LONG + ).show() + } + + override fun onResponse(call: Call, response: Response) { + + if (response.code() != 200) { + Toast.makeText( + applicationContext, + R.string.login_wrong_field, + Toast.LENGTH_LONG + ).show() + return + } + val users = response.body() + sph.baseUrl = instanceServer.text.toString() + sph.token = users?.token + sph.userId = users?.id + + Toast.makeText( + applicationContext, + R.string.login_success, + Toast.LENGTH_LONG + ).show() + + setResult(LOGIN_OK) + finish() + } + }) + } + + companion object { + const val LOGIN_OK = 0 + const val LOGIN_ERROR = 1 + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivity.java b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivity.java new file mode 100644 index 0000000..887b245 --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/NewBoardActivity.java @@ -0,0 +1,69 @@ +package it.unisannio.ding.ids.wedroid.app.view; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import android.util.Log; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.Toast; + +import it.unisannio.ding.ids.wedroid.app.R; +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardBackgroundColor; + +public class NewBoardActivity extends AppCompatActivity { + private EditText boardName; + private Switch isPrivate; + private Spinner colorPicker; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_new_board); + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + boardName = findViewById(R.id.newBoardName); + isPrivate = findViewById(R.id.newBoardPermission); + + colorPicker = findViewById(R.id.newBoardColor); + ArrayAdapter adapter = ArrayAdapter.createFromResource( + this, + R.array.board_background_colors, + android.R.layout.simple_spinner_item + ); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + colorPicker.setAdapter(adapter); + + } + + public void onDone(View v) { + if (boardName.getText().toString().equals("")) { + Toast.makeText(this, R.string.on_add_new_board_empty_name, Toast.LENGTH_LONG) + .show(); + return; + } + + Intent data = new Intent(); + data.putExtra(BOARD_NAME, boardName.getText().toString()); + data.putExtra(BOARD_PRIVATE, isPrivate.isChecked()); + data.putExtra(BOARD_BACKGROUND_COLOR, colorPicker.getSelectedItem().toString()); + setResult(RESULT_OK, data); + finish(); + } + + public void onCancel(View v) { + finish(); + } + + public static final int RESULT_OK = 17; + public static final String BOARD_NAME = "BOARD_NAME"; + public static final String BOARD_PRIVATE = "BOARD_PRIVATE"; + public static final String BOARD_BACKGROUND_COLOR = "BOARD_BACKGROUND_COLOR"; +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/adapter/BoardsListAdapter.kt b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/adapter/BoardsListAdapter.kt new file mode 100644 index 0000000..7ea269c --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/view/adapter/BoardsListAdapter.kt @@ -0,0 +1,54 @@ +package it.unisannio.ding.ids.wedroid.app.view.adapter + +import android.content.Context +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import it.unisannio.ding.ids.wedroid.app.R +import it.unisannio.ding.ids.wedroid.app.data.entity.Board + +class BoardsListAdapter internal constructor( + context: Context +) : RecyclerView.Adapter() { + + private val inflater = LayoutInflater.from(context) + private var boards = emptyList() + + inner class BoardViewHolder( + view: View + ) : RecyclerView.ViewHolder(view) { + val boardTitle: TextView = view.findViewById(R.id.boardTitle) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BoardViewHolder { + val view = inflater.inflate(R.layout.board_recycle_item, parent, false) + return BoardViewHolder(view) + } + + override fun getItemCount(): Int { + return boards.size + } + + override fun onBindViewHolder(holder: BoardViewHolder, position: Int) { + val board = boards[position] + holder.boardTitle.text = board.title + + holder.itemView.setOnClickListener { + val intent = Intent(it.context, TODO()) + intent.putExtra(BOARD_ID, board.id) + it.context.startActivity(intent) + } + } + + internal fun setBoards(boards: List) { + this.boards = boards + notifyDataSetChanged() + } + + companion object { + const val BOARD_ID = "BOARD_ID" + } +} diff --git a/app/src/main/java/it/unisannio/ding/ids/wedroid/app/viewmodel/BoardsListViewModel.java b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/viewmodel/BoardsListViewModel.java new file mode 100644 index 0000000..0d1a79e --- /dev/null +++ b/app/src/main/java/it/unisannio/ding/ids/wedroid/app/viewmodel/BoardsListViewModel.java @@ -0,0 +1,53 @@ +package it.unisannio.ding.ids.wedroid.app.viewmodel; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import java.util.List; + +import it.unisannio.ding.ids.wedroid.app.data.database.BoardDatabase; +import it.unisannio.ding.ids.wedroid.app.data.entity.Board; +import it.unisannio.ding.ids.wedroid.app.data.repository.BoardRepository; +import it.unisannio.ding.ids.wedroid.app.util.PreferenceReader; +import it.unisannio.ding.ids.wedroid.app.util.ServicesFactory; +import it.unisannio.ding.ids.wedroid.app.util.SharedPreferenceHelper; +import it.unisannio.ding.ids.wedroid.wrapper.entity.BoardBackgroundColor; + +public class BoardsListViewModel extends AndroidViewModel { + private BoardRepository repository; + private LiveData> allBoards; + + public BoardsListViewModel(@NonNull Application application) { + super(application); + PreferenceReader reader = new SharedPreferenceHelper(application); + repository = new BoardRepository( + BoardDatabase.getDatabase(application).boardDao(), + ServicesFactory.Companion.getInstance(reader).getBoardService(), + reader + ); + + allBoards = repository.getAllBoards(); + } + + public LiveData> getAllBoards() { + return allBoards; + } + + public void insertBoard(String title, boolean isPrivate, BoardBackgroundColor color) { + repository.insertBoard(title, isPrivate, color); + } + + public void deleteBoard(int position) { + List boards = allBoards.getValue(); + + if (boards != null) + repository.deleteBoard(boards.get(position).getId()); + } + + public void refresh() { + repository.synchronize(); + } +} diff --git a/app/src/main/res/drawable/ic_add_black_24dp.xml b/app/src/main/res/drawable/ic_add_black_24dp.xml new file mode 100644 index 0000000..6b6146e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_boards_lists.xml b/app/src/main/res/layout/activity_boards_lists.xml new file mode 100644 index 0000000..7282fa5 --- /dev/null +++ b/app/src/main/res/layout/activity_boards_lists.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..1d6bf2a --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,56 @@ + + + + + + + +