diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c2d908a..72bf4c7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,10 +3,8 @@ plugins { } android { - namespace = "com.campusaula.edbole.KanbanCloneAndroid" - compileSdk { - version = release(36) - } + namespace = "com.campusaula.edbole.kanban_clone_android" + compileSdk = 36 defaultConfig { applicationId = "com.campusaula.edbole.KanbanCloneAndroid" @@ -39,6 +37,19 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + + implementation("com.squareup.okhttp3:okhttp:4.11.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + + implementation("com.auth0.android:jwtdecode:2.0.1") + implementation(libs.androidx.recyclerview) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/androidTest/java/com/campusaula/edbole/KanbanCloneAndroid/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/campusaula/edbole/kanban_clone_android/ExampleInstrumentedTest.kt similarity index 92% rename from app/src/androidTest/java/com/campusaula/edbole/KanbanCloneAndroid/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/campusaula/edbole/kanban_clone_android/ExampleInstrumentedTest.kt index 1548188..d6c0fca 100644 --- a/app/src/androidTest/java/com/campusaula/edbole/KanbanCloneAndroid/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/campusaula/edbole/kanban_clone_android/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.campusaula.edbole.KanbanCloneAndroid +package com.campusaula.edbole.kanban_clone_android import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 26642ca..d5f2638 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + android:theme="@style/Theme.KanbanCloneAndroid" + android:usesCleartextTraffic="true"> + + + + + + + diff --git a/app/src/main/java/com/campusaula/edbole/KanbanCloneAndroid/MainActivity.kt b/app/src/main/java/com/campusaula/edbole/KanbanCloneAndroid/MainActivity.kt deleted file mode 100644 index 4a9ba34..0000000 --- a/app/src/main/java/com/campusaula/edbole/KanbanCloneAndroid/MainActivity.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.campusaula.edbole.KanbanCloneAndroid - -import android.os.Bundle -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt new file mode 100644 index 0000000..63af700 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt @@ -0,0 +1,17 @@ +package com.campusaula.edbole.kanban_clone_android.kanban + +data class LoginResponse( + val message: String?, + val user: LoginUser? +) + +data class LoginUser( + val id: String?, + val name: String?, + val email: String? +) + +// Error response from the API (e.g. 401 Unauthorized) +data class ErrorResponse( + val detail: String? +) diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Project.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Project.kt new file mode 100644 index 0000000..70c8c21 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Project.kt @@ -0,0 +1,32 @@ +package com.campusaula.edbole.kanban_clone_android.kanban + +import com.google.gson.annotations.SerializedName + +class Project{ + val id: Int = 0 + val name: String = "" + val description: String = "" + val users: List = emptyList() + val tasks: List = emptyList() + + + override fun toString(): String { + return "Project(id=$id, name='$name', description='$description', users=$users, tasks=$tasks)" + } +} + +data class ProjectBase( + @SerializedName("id") + val id : Int, + @SerializedName("name") + val name : String, + @SerializedName("description") + val description : String +) + +data class ProjectCreate( + @SerializedName("name") + val name : String, + @SerializedName("description") + val description : String +) diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Task.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Task.kt new file mode 100644 index 0000000..87f99c5 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Task.kt @@ -0,0 +1,49 @@ +package com.campusaula.edbole.kanban_clone_android.kanban + +import com.google.gson.annotations.SerializedName + +enum class TaskStatus { + @SerializedName("pending") + PENDING, + @SerializedName("in_progress") + IN_PROGRESS, + @SerializedName("completed") + COMPLETED, + @SerializedName("failed") + FAILED, + @SerializedName("stashed") + STASHED +} + +class Task { + val id: Int = 0 + val title: String = "" + val description: String = "" + var status: TaskStatus = TaskStatus.PENDING + val project: Project? = null +} + +data class TaskBase( + @SerializedName("id") + val id : Int, + + @SerializedName("title") + val title : String, + + @SerializedName("description") + val description : String, + + @SerializedName("status") + val status: TaskStatus +) + +data class TaskUpdate( + @SerializedName("title") + val title : String?, + + @SerializedName("description") + val description : String?, + + @SerializedName("status") + val status: TaskStatus? +) diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/User.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/User.kt new file mode 100644 index 0000000..3c4496c --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/User.kt @@ -0,0 +1,55 @@ +package com.campusaula.edbole.kanban_clone_android.kanban + +import com.google.gson.annotations.SerializedName + +class User { + val id: Int = 0 + val name : String = "" + val email: String = "" + val password: String = "" + val projects: List = emptyList() +} + +data class UserBase ( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, + @SerializedName("email") + val email: String +) + +data class ProjectUser( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, + @SerializedName("email") + val email: String, + @SerializedName("projects") + val projects: List +) + +data class UserLogin ( + @SerializedName("email") + val email: String, + @SerializedName("password") + val password: String +) + +data class UserCreate ( + @SerializedName("name") + val name: String, + @SerializedName("email") + val email: String, + @SerializedName("password") + val password: String +) + +data class UserUpdatePassword( + @SerializedName("password") + val password: String, + @SerializedName("new_password") + val newPassword: String +) + diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt new file mode 100644 index 0000000..296b6a1 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt @@ -0,0 +1,85 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import com.campusaula.edbole.kanban_clone_android.kanban.* +import retrofit2.Response +import retrofit2.http.* + +interface ApiService { + @GET("/ping") + suspend fun ping(): Response + + @POST("auth/login/") + suspend fun login(@Body userLogin: UserLogin): Response + + @GET("me/logout/") + suspend fun logout(): Response + + @DELETE("me/delete-me/") + suspend fun deleteMe(): Response + + @GET("me/") + suspend fun getMe(): Response + + @GET("users/{user_id}/") + suspend fun getUserById(@Path("user_id") userId: Int): Response + + @GET("users/{user_id}/projects/") + suspend fun getUserProjectsByUserId(@Path("user_id") userId: Int): Response> + + @POST("users/") + suspend fun createUser(@Body userLogin: UserCreate): Response + + // Projects endpoints + + @GET("projects/") + suspend fun getAllProjects(): Response> + + @GET("projects/{project_id}/") + suspend fun getProjectById(@Path("project_id") projectId: Int): Response + + @GET("projects/{project_id}/users/") + suspend fun getProjectUsers(@Path("project_id") projectId: Int): Response> + + @POST("projects/{project_id}/users/") + suspend fun addProjectCollaborator( + @Path("project_id") projectId: Int, + @Body email: Map + ): Response + + @DELETE("projects/{project_id}/users/{user_id}/") + suspend fun removeProjectCollaborator( + @Path("project_id") projectId: Int, + @Path("user_id") userId: Int + ): Response + + @POST("projects/") + suspend fun createProject(@Body projectCreate: ProjectCreate): Response + + @PUT("projects/{project_id}/") + suspend fun updateProject(@Path("project_id") projectId: Int, @Body projectCreate: ProjectCreate): Response + + @DELETE("projects/{project_id}/") + suspend fun deleteProject(@Path("project_id") projectId: Int): Response + + // Tasks endpoints + + @GET("projects/{project_id}/tasks/") + suspend fun getProjectTasks(@Path("project_id") projectId: Int): Response> + + @POST("projects/{project_id}/tasks/") + suspend fun createTask(@Path("project_id") projectId: Int, @Body taskBase: TaskBase): Response + + @PUT("projects/{project_id}/tasks/{task_id}/") + suspend fun updateProjectTask( + @Path("project_id") projectId: Int, + @Path("task_id") taskId: Int, + @Body taskUpdate: TaskUpdate + ): Response + + @DELETE("projects/{project_id}/tasks/{task_id}/") + suspend fun deleteProjectTask( + @Path("project_id") projectId: Int, + @Path("task_id") taskId: Int + ): Response + +} diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt new file mode 100644 index 0000000..f9ee3f4 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt @@ -0,0 +1,186 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import com.google.gson.Gson +import androidx.core.content.edit +import java.util.concurrent.ConcurrentHashMap +import android.util.Base64 +import com.auth0.android.jwt.JWT + + +private data class StoredCookie( + val name: String, + val value: String, + val expiresAt: Long, + val domain: String, + val path: String, + val secure: Boolean, + val httpOnly: Boolean, + val hostOnly: Boolean, + val iat: Long? = null, + val exp: Long? = null +) + +class AuthCookieJar( + context: Context, + private val authCookieNames: Set = setOf("access_token", "session", "auth", "auth_token", "JSESSIONID") +) : CookieJar { + + private val prefs = context.applicationContext.getSharedPreferences("auth_cookie_prefs", Context.MODE_PRIVATE) + private val lock = Any() + private val gson = Gson() + private val knownHosts = ConcurrentHashMap.newKeySet() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) return + val hostKey = url.host + synchronized(lock) { + val existing = prefs.getStringSet(hostKey, emptySet())?.toMutableSet() ?: mutableSetOf() + val now = System.currentTimeMillis() + for (cookie in cookies) { + if (cookie.expiresAt <= now) continue + existing.removeIf { it.startsWith("${'$'}{cookie.name}|${'$'}{cookie.path}|") } + + // default stored expiresAt comes from cookie.expiresAt (already in ms) + var storedExpires = cookie.expiresAt + var iatVal: Long? = null + var expVal: Long? = null + + // if this is an auth cookie, try to decode JWT payload to obtain exp/iat + if (cookie.name in authCookieNames) { + try { + val (expSec, iatSec) = decodeJwtExpIat(cookie.value) + if (expSec != null) { + expVal = expSec + // convert to millis + storedExpires = expSec * 1000L + } + if (iatSec != null) { + iatVal = iatSec + } + } catch (_: Exception) { + // ignore, keep cookie.expiresAt + } + } + + val stored = StoredCookie( + name = cookie.name, + value = cookie.value, + expiresAt = storedExpires, + domain = cookie.domain, + path = cookie.path, + secure = cookie.secure, + httpOnly = cookie.httpOnly, + hostOnly = cookie.hostOnly, + iat = iatVal, + exp = expVal + ) + // serialize to json explicitly and use it + val json = gson.toJson(stored) + existing.add("${cookie.name}|${cookie.path}|${json}") + } + prefs.edit { putStringSet(hostKey, existing) } + knownHosts.add(hostKey) + } + } + + override fun loadForRequest(url: HttpUrl): List { + val hostKey = url.host + val result = ArrayList() + val now = System.currentTimeMillis() + synchronized(lock) { + val set = prefs.getStringSet(hostKey, emptySet()) ?: emptySet() + val newSet = mutableSetOf() + for (s in set) { + try { + val jsonStart = s.indexOf('{') + val json = if (jsonStart >= 0) s.substring(jsonStart) else s + val stored = gson.fromJson(json, StoredCookie::class.java) + if (stored.expiresAt <= now) continue + val builder = Cookie.Builder() + .name(stored.name) + .value(stored.value) + .expiresAt(stored.expiresAt) + .path(stored.path) + if (stored.hostOnly) builder.hostOnlyDomain(stored.domain) else builder.domain(stored.domain) + if (stored.secure) builder.secure() + if (stored.httpOnly) builder.httpOnly() + val cookie = builder.build() + if (cookie.matches(url)) { + result.add(cookie) + } + newSet.add(s) + } catch (_: Exception) { + // skip malformed + } + } + prefs.edit { putStringSet(hostKey, newSet) } + if (newSet.isNotEmpty()) knownHosts.add(hostKey) + } + return result + } + + fun saveFromSetCookieHeader(setCookieHeader: String, requestUrl: String) { + val url = requestUrl.toHttpUrlOrNull() ?: return + val lines = setCookieHeader.split('\n').map { it.trim() }.filter { it.isNotEmpty() } + val parsed = mutableListOf() + for (line in lines) { + Cookie.parse(url, line)?.let { parsed.add(it) } + } + if (parsed.isNotEmpty()) saveFromResponse(url, parsed) + } + + fun getCookieHeaderForUrl(urlString: String): String? { + val url = urlString.toHttpUrlOrNull() ?: return null + val cookies = loadForRequest(url) + if (cookies.isEmpty()) return null + return cookies.joinToString("; ") { "${'$'}{it.name}=${'$'}{it.value}" } + } + + fun getAuthCookieForUrl(urlString: String): String? { + val url = urlString.toHttpUrlOrNull() ?: return null + val host = url.host + val set = prefs.getStringSet(host, emptySet()) ?: return null + val now = System.currentTimeMillis() + for (s in set) { + try { + val jsonStart = s.indexOf('{') + val json = if (jsonStart >= 0) s.substring(jsonStart) else s + val stored = gson.fromJson(json, StoredCookie::class.java) + if (stored.expiresAt <= now) continue + if (stored.name in authCookieNames) return stored.value + } catch (_: Exception) { + } + } + return null + } + + /** Remove all cookies stored for the given host. */ + fun clearCookiesForHost(host: String) { + synchronized(lock) { + prefs.edit { remove(host) } + knownHosts.remove(host) + } + } + + private fun decodeJwtExpIat(token: String): Pair { + return try { + val jwt = JWT(token) + // expiresAt / issuedAt -> java.util.Date? + val expSec = jwt.expiresAt?.time?.div(1000) // segundos desde epoch + val iatSec = jwt.issuedAt?.time?.div(1000) + Pair(expSec, iatSec) + } catch (e: Exception) { + Pair(null, null) + } + } + + private fun padBase64(b64: String): String { + val rem = b64.length % 4 + return if (rem == 0) b64 else b64 + "=".repeat(4 - rem) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt new file mode 100644 index 0000000..c0c778e --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt @@ -0,0 +1,58 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import android.content.Context +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitInstance { + private const val BASE_URL = "http://10.0.2.2:8000/" + + @Volatile + private var retrofit: Retrofit? = null + private var cookieJar: AuthCookieJar? = null + + private val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + fun getRetrofit(context: Context): Retrofit { + return retrofit ?: synchronized(this) { + retrofit ?: buildRetrofit(context.applicationContext).also { retrofit = it } + } + } + + private fun buildRetrofit(context: Context): Retrofit { + cookieJar = AuthCookieJar(context) + + val client = OkHttpClient.Builder() + .cookieJar(cookieJar!!) + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + /** Helper: obtiene el valor de la cookie de autenticación para una URL. */ + fun getAuthCookieForUrl(url: String): String? { + return cookieJar?.getAuthCookieForUrl(url) + } + + fun getCookieHeaderForUrl(url: String): String? { + return cookieJar?.getCookieHeaderForUrl(url) + } + + fun clearCookiesForHost(host: String) { + cookieJar?.clearCookiesForHost(host) + } + + fun cookieJarInstance(): AuthCookieJar? = cookieJar +} diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CollaboratorAddActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CollaboratorAddActivity.kt new file mode 100644 index 0000000..e68042e --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CollaboratorAddActivity.kt @@ -0,0 +1,126 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.util.Patterns +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class CollaboratorAddActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton: FloatingActionButton + private lateinit var collaboratorEmailInput: EditText + private lateinit var addCollaboratorButton: Button + + private var projectId: Int = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_collaborator_add) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + + // Get project ID from intent + projectId = intent.getIntExtra("project_id", -1) + + if (projectId == -1) { + Toast.makeText(this, "Error: Invalid project ID", Toast.LENGTH_SHORT).show() + finish() + return + } + + // Initialize views + returnActionButton = findViewById(R.id.returnActionButton) + collaboratorEmailInput = findViewById(R.id.collaboratorEmailInput) + addCollaboratorButton = findViewById(R.id.addCollaboratorButton) + + // Set up button listeners + returnActionButton.setOnClickListener { + finish() + + val intent = Intent(this@CollaboratorAddActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + + } + + addCollaboratorButton.setOnClickListener { + addCollaborator() + } + } + + private fun addCollaborator() { + val user_email = collaboratorEmailInput.text.toString().trim() + + // Validate email + if (user_email.isEmpty()) { + Toast.makeText(this, "Email cannot be empty", Toast.LENGTH_SHORT).show() + return + } + + if (!Patterns.EMAIL_ADDRESS.matcher(user_email).matches()) { + Toast.makeText(this, "Please enter a valid email address", Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + try { + Log.d("CollaboratorAddActivity", "Adding collaborator: $user_email") + val emailBody = mapOf("user_email" to user_email) + val response = api.addProjectCollaborator(projectId, emailBody) + + if (response.isSuccessful) { + Log.d("CollaboratorAddActivity", "Collaborator added successfully") + Toast.makeText( + this@CollaboratorAddActivity, + "Collaborator added successfully", + Toast.LENGTH_SHORT + ).show() + setResult(RESULT_OK) + finish() + + // Reopen ProjectDetailActivity to show the new collaborator + val intent = Intent(this@CollaboratorAddActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("CollaboratorAddActivity", "Error adding collaborator: $errorBody") + Toast.makeText( + this@CollaboratorAddActivity, + "Error adding collaborator: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("CollaboratorAddActivity", "Exception adding collaborator: ${e.message}") + Toast.makeText( + this@CollaboratorAddActivity, + "Failed to add collaborator: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } +} + diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CreateProjectActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CreateProjectActivity.kt new file mode 100644 index 0000000..521add0 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/CreateProjectActivity.kt @@ -0,0 +1,103 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.ProjectCreate +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + + +class CreateProjectActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton : FloatingActionButton + private lateinit var newProjectName : EditText + private lateinit var newProjectDescription : EditText + private lateinit var newProjectCreateButton : Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_create_project) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + + returnActionButton = findViewById(R.id.returnActionButton) + returnActionButton.setOnClickListener { + finish() + + val intent = Intent(this@CreateProjectActivity, MainActivity::class.java) + startActivity(intent) + } + + newProjectName = findViewById(R.id.newProjectName) + newProjectDescription = findViewById(R.id.newProjectDescription) + + newProjectCreateButton = findViewById(R.id.newProjectCreateButton) + newProjectCreateButton.setOnClickListener { + val projectName = newProjectName.text.toString().trim() + val projectDescription = newProjectDescription.text.toString().trim() + + if (projectName.isEmpty()) { + Toast.makeText(this, "Project name cannot be empty", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + lifecycleScope.launch { + try { + Log.d("CreateProjectActivity", "Creating project: $projectName") + val projectCreate = ProjectCreate(projectName, projectDescription) + val response = api.createProject(projectCreate) + + if (response.isSuccessful) { + Log.d("CreateProjectActivity", "Project created successfully: ${response.body()}") + Toast.makeText( + this@CreateProjectActivity, + "Project created successfully", + Toast.LENGTH_SHORT + ).show() + finish() + + // Volver a MainActivity para ver el nuevo proyecto + val intent = Intent(this@CreateProjectActivity, MainActivity::class.java) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("CreateProjectActivity", "Error creating project: $errorBody") + Toast.makeText( + this@CreateProjectActivity, + "Error creating project: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("CreateProjectActivity", "Exception creating project: ${e.message}") + Toast.makeText( + this@CreateProjectActivity, + "Failed to create project: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/LoginActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/LoginActivity.kt new file mode 100644 index 0000000..8b08d11 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/LoginActivity.kt @@ -0,0 +1,111 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.campusaula.edbole.kanban_clone_android.kanban.ErrorResponse +import com.campusaula.edbole.kanban_clone_android.kanban.UserLogin +import com.google.gson.Gson +import kotlinx.coroutines.launch +import retrofit2.Retrofit + +class LoginActivity : AppCompatActivity() { + + private lateinit var emailInput : AppCompatEditText + private lateinit var passwordInput : AppCompatEditText + + private lateinit var loginButton : AppCompatButton + private lateinit var logonButton : AppCompatButton + + private lateinit var retrofit : Retrofit + private lateinit var api : ApiService + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_login) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + retrofit = RetrofitInstance.getRetrofit(applicationContext) + api = retrofit.create(ApiService::class.java) + + emailInput = findViewById(R.id.emailInput) + passwordInput = findViewById(R.id.passwordInput) + loginButton = findViewById(R.id.loginButton) + logonButton = findViewById(R.id.logonButton) + + loginButton.setOnClickListener { + val email = emailInput.text.toString() + val password = passwordInput.text.toString() + + if (email.isEmpty() && password.isEmpty()) { + Toast.makeText(this, "Please enter email and password", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + lifecycleScope.launch{ + try { + val loginResponse = api.login( + UserLogin( + email = email, + password = password + ) + ) + + val baseUrl = retrofit.baseUrl().toString() + val baseHost = retrofit.baseUrl().host + + if (loginResponse.isSuccessful) { + // Después del login exitoso OkHttp/CookieJar habrá guardado las cookies. + val authValue = RetrofitInstance.getAuthCookieForUrl(baseUrl) + if (authValue != null) { + Toast.makeText(this@LoginActivity, "Auth cookie guardada", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@LoginActivity, "Login OK pero no se encontró cookie de auth", Toast.LENGTH_SHORT).show() + } + // Navegar a MainActivity + val intent = Intent(this@LoginActivity, MainActivity::class.java) + startActivity(intent) + finish() + } else { + if (loginResponse.code() == 401) { + // parse error body if possible + val errBody = loginResponse.errorBody()?.string() + val gson = Gson() + val errMsg = try { + val err = gson.fromJson(errBody, ErrorResponse::class.java) + err.detail ?: "Unauthorized" + } catch (_: Exception) { + errBody ?: "Unauthorized" + } + // clear stored cookies for base host + RetrofitInstance.clearCookiesForHost(baseHost) + + Toast.makeText(this@LoginActivity, "Login failed (401): $errMsg", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@LoginActivity, "Login failed: ${loginResponse.code()}", Toast.LENGTH_SHORT).show() + } + } + + } catch (ex: Exception){ + Toast.makeText(this@LoginActivity, "Login failed: ${ex.message}", Toast.LENGTH_SHORT).show() + } + + } + } + } +} diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/MainActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/MainActivity.kt new file mode 100644 index 0000000..2dcfa11 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/MainActivity.kt @@ -0,0 +1,103 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.Project +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.campusaula.edbole.kanban_clone_android.ui.adapters.ProjectItemAdapter +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private lateinit var api: ApiService + private lateinit var projectList : List + + private lateinit var loggedInAs: TextView + private lateinit var logoutButton: Button + private lateinit var addProjectActionButton: FloatingActionButton + private lateinit var projectsRecyclerView: RecyclerView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_main) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + projectList = emptyList() + + /* Activity components */ + loggedInAs = findViewById(R.id.loggedInAs) + logoutButton = findViewById(R.id.logoutButton) + addProjectActionButton = findViewById(R.id.addProjectActionButton) + projectsRecyclerView = findViewById(R.id.projectsRecyclerView) + projectsRecyclerView.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this) + val adapter = ProjectItemAdapter(projectList) { project -> + val intent = Intent(this, ProjectDetailActivity::class.java) + intent.putExtra("project_id", project.id) + startActivity(intent) + } + projectsRecyclerView.adapter = adapter + + addProjectActionButton.setOnClickListener { + val intent = Intent(this, CreateProjectActivity::class.java) + startActivity(intent) + } + + /* Getting the logged-in user info */ + lifecycleScope.launch{ + + val getMe = api.getMe() + if (getMe.isSuccessful){ + val user = getMe.body() + loggedInAs.text = "Logged in as: ${user?.name}" + projectList = api.getAllProjects().body()!! + adapter.submitList(projectList) + } else { + val intent = Intent(this@MainActivity, LoginActivity::class.java) + startActivity(intent) + } + + } + + logoutButton.setOnClickListener { + lifecycleScope.launch { + val logoutResponse = api.logout() + if (logoutResponse.isSuccessful) { + // Clear cookies for the API host + RetrofitInstance.clearCookiesForHost( + "10.0.2.2" + ) + + // Navigate back to the login screen + val intent = + Intent(this@MainActivity, LoginActivity::class.java) + startActivity(intent) + finish() // Optional: close the MainActivity so it's removed from the back stack + } else { + Toast.makeText( + this@MainActivity, + "Logout failed", + Toast.LENGTH_SHORT + ).show() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectDetailActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectDetailActivity.kt new file mode 100644 index 0000000..4db305c --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectDetailActivity.kt @@ -0,0 +1,255 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.health.connect.datatypes.units.Percentage +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.TextView +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.Project +import com.campusaula.edbole.kanban_clone_android.kanban.Task +import com.campusaula.edbole.kanban_clone_android.kanban.TaskStatus +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.campusaula.edbole.kanban_clone_android.ui.adapters.ProjectCollaboratorAdapter +import com.campusaula.edbole.kanban_clone_android.ui.adapters.ProjectTaskAdapter +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class ProjectDetailActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton: FloatingActionButton + private lateinit var addTaskButton: Button + private lateinit var addCollaboratorButton: Button + private lateinit var editProjectButton: Button + private lateinit var deleteProjectButton: Button + + private lateinit var taskListRecycler: RecyclerView + private lateinit var collaboratorListRecycler: RecyclerView + private lateinit var collaboratorListAdapter: ProjectCollaboratorAdapter + private lateinit var taskListAdapter: ProjectTaskAdapter + + + private lateinit var projectTitleText : TextView + private lateinit var projectDescriptionText : TextView + private lateinit var completedPercentageText: TextView + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_project_detail) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + val projectId = intent.getIntExtra("project_id", -1) + + projectTitleText = findViewById(R.id.projectTitleText) + projectDescriptionText = findViewById(R.id.projectDescriptionText) + completedPercentageText = findViewById(R.id.completedPercentageText) + + returnActionButton = findViewById(R.id.returnActionButton) + returnActionButton.setOnClickListener { finish() } + + addTaskButton = findViewById(R.id.addTaskButton) + addTaskButton.setOnClickListener { + val intent = Intent(this, TaskAddActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + finish() + } + + addCollaboratorButton = findViewById(R.id.addCollaboratorButton) + addCollaboratorButton.setOnClickListener { + val intent = Intent(this, CollaboratorAddActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + finish() + } + + taskListRecycler = findViewById(R.id.taskListRecycler) + taskListRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this) + taskListAdapter = ProjectTaskAdapter(emptyList(), api, projectId, { + updateCompletionRate() + }, { + finish() + }) + taskListRecycler.adapter = taskListAdapter + + collaboratorListRecycler = findViewById(R.id.collaboratorListRecycler) + collaboratorListRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this) + collaboratorListAdapter = ProjectCollaboratorAdapter(emptyList(), api, projectId) { + updateCollaboratorList() + } + collaboratorListRecycler.adapter = collaboratorListAdapter + + // Danger Zone buttons + editProjectButton = findViewById(R.id.editProjectButton) + editProjectButton.setOnClickListener { + val intent = Intent(this, ProjectEditActivity::class.java) + intent.putExtra("project_id", projectId) + intent.putExtra("project_name", projectTitleText.text.toString()) + intent.putExtra("project_description", projectDescriptionText.text.toString()) + startActivity(intent) + finish() + } + + deleteProjectButton = findViewById(R.id.deleteProjectButton) + deleteProjectButton.setOnClickListener { + deleteProject(projectId) + } + + if (projectId > 0) { + Log.d("ProjectDetailActivity", "Received project ID: $projectId") + lifecycleScope.launch { + try { + val projectResponse = api.getProjectById(projectId) + + if (projectResponse.isSuccessful && projectResponse.body() != null) { + Log.d("ProjectDetailActivity", "Fetched project: ${projectResponse.body()!!.name}") + val project = projectResponse.body()!! + + + Log.d("ProjectDetailActivity", "Displaying project details for: $project") + + projectTitleText.text = project.name + projectDescriptionText.text = project.description + + var percentageFinished = 0.0; + val collaborators = project.users + val tasks: List = project.tasks + val totalTasks: Int = tasks.size + + var completedTasks = 0 + for (task in tasks) { + if (task.status == TaskStatus.COMPLETED) { + completedTasks++ + } + } + + percentageFinished = if (totalTasks > 0) (completedTasks.toDouble() / totalTasks.toDouble()) * 100 else 0.0 + completedPercentageText.text = "Completed: ${"%.2f".format(percentageFinished)}%" + + taskListAdapter.submitList(tasks.toMutableList()) + collaboratorListAdapter.submitList(collaborators.toMutableList()) + + + } else { + Log.e("ProjectDetailActivity", "Failed to fetch project: ${projectResponse.code()} - ${projectResponse.message()}") + finish() + } + } catch (e: Exception) { + Log.e("ProjectDetailActivity", "Error fetching project", e) + finish() + } + } + } + else { + Log.e("ProjectDetailActivity", "No project ID found in intent") + finish() + } + + + + } + + private fun updateCompletionRate() { + lifecycleScope.launch { + try { + val projectId = intent.getIntExtra("project_id", -1) + val projectResponse = api.getProjectById(projectId) + + if (projectResponse.isSuccessful && projectResponse.body() != null) { + val project = projectResponse.body()!! + val tasks: List = project.tasks + val totalTasks: Int = tasks.size + + var completedTasks = 0 + for (task in tasks) { + if (task.status == TaskStatus.COMPLETED) { + completedTasks++ + } + } + + val percentageFinished = if (totalTasks > 0) (completedTasks.toDouble() / totalTasks.toDouble()) * 100 else 0.0 + completedPercentageText.text = "Completed: ${"%.2f".format(percentageFinished)}%" + + // Actualizar la lista de tareas también + taskListAdapter.submitList(tasks.toMutableList()) + } + } catch (e: Exception) { + Log.e("ProjectDetailActivity", "Error updating completion rate", e) + } + } + } + + private fun updateCollaboratorList() { + lifecycleScope.launch { + try { + val projectId = intent.getIntExtra("project_id", -1) + val projectResponse = api.getProjectById(projectId) + + if (projectResponse.isSuccessful && projectResponse.body() != null) { + val project = projectResponse.body()!! + val collaborators = project.users + + collaboratorListAdapter.submitList(collaborators.toMutableList()) + } + } catch (e: Exception) { + Log.e("ProjectDetailActivity", "Error updating collaborator list", e) + } + } + } + + private fun deleteProject(projectId: Int) { + lifecycleScope.launch { + try { + Log.d("ProjectDetailActivity", "Deleting project: $projectId") + val response = api.deleteProject(projectId) + + if (response.isSuccessful) { + Log.d("ProjectDetailActivity", "Project deleted successfully") + android.widget.Toast.makeText( + this@ProjectDetailActivity, + "Project deleted successfully", + android.widget.Toast.LENGTH_SHORT + ).show() + finish() + + // Volver a MainActivity + val intent = Intent(this@ProjectDetailActivity, MainActivity::class.java) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("ProjectDetailActivity", "Error deleting project: $errorBody") + android.widget.Toast.makeText( + this@ProjectDetailActivity, + "Error deleting project: ${response.code()}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("ProjectDetailActivity", "Exception deleting project: ${e.message}") + android.widget.Toast.makeText( + this@ProjectDetailActivity, + "Failed to delete project: ${e.message}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectEditActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectEditActivity.kt new file mode 100644 index 0000000..0948826 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/ProjectEditActivity.kt @@ -0,0 +1,134 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.ProjectCreate +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class ProjectEditActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton: FloatingActionButton + private lateinit var projectNameInput: EditText + private lateinit var projectDescriptionInput: EditText + private lateinit var saveProjectButton: Button + + private var projectId: Int = -1 + private var currentName: String = "" + private var currentDescription: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_project_edit) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + + // Get project data from intent + projectId = intent.getIntExtra("project_id", -1) + currentName = intent.getStringExtra("project_name") ?: "" + currentDescription = intent.getStringExtra("project_description") ?: "" + + if (projectId == -1) { + Toast.makeText(this, "Error: Invalid project ID", Toast.LENGTH_SHORT).show() + finish() + return + } + + // Initialize views + returnActionButton = findViewById(R.id.returnActionButton) + projectNameInput = findViewById(R.id.projectNameInput) + projectDescriptionInput = findViewById(R.id.projectDescriptionInput) + saveProjectButton = findViewById(R.id.saveProjectButton) + + // Populate fields with current project data + projectNameInput.setText(currentName) + projectDescriptionInput.setText(currentDescription) + + // Set up button listeners + returnActionButton.setOnClickListener { + finish() + + val intent = Intent(this@ProjectEditActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } + + saveProjectButton.setOnClickListener { + saveProject() + } + } + + private fun saveProject() { + val newName = projectNameInput.text.toString().trim() + val newDescription = projectDescriptionInput.text.toString().trim() + + if (newName.isEmpty()) { + Toast.makeText(this, "Project name cannot be empty", Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + try { + Log.d("ProjectEditActivity", "Updating project: $projectId") + val projectCreate = ProjectCreate( + name = newName, + description = newDescription + ) + + val response = api.updateProject(projectId, projectCreate) + + if (response.isSuccessful) { + Log.d("ProjectEditActivity", "Project updated successfully") + Toast.makeText( + this@ProjectEditActivity, + "Project updated successfully", + Toast.LENGTH_SHORT + ).show() + setResult(RESULT_OK) + finish() + + // Reopen ProjectDetailActivity to show the updated project + val intent = Intent(this@ProjectEditActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("ProjectEditActivity", "Error updating project: $errorBody") + Toast.makeText( + this@ProjectEditActivity, + "Error updating project: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("ProjectEditActivity", "Exception updating project: ${e.message}") + Toast.makeText( + this@ProjectEditActivity, + "Failed to update project: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } +} + diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskAddActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskAddActivity.kt new file mode 100644 index 0000000..37d764c --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskAddActivity.kt @@ -0,0 +1,136 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.Spinner +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.TaskBase +import com.campusaula.edbole.kanban_clone_android.kanban.TaskStatus +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class TaskAddActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton: FloatingActionButton + private lateinit var taskTitleInput: EditText + private lateinit var taskDescriptionInput: EditText + private lateinit var taskStatusSpinner: Spinner + private lateinit var createTaskButton: Button + + private var projectId: Int = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_task_add) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + + // Get project ID from intent + projectId = intent.getIntExtra("project_id", -1) + + if (projectId == -1) { + Toast.makeText(this, "Error: Invalid project ID", Toast.LENGTH_SHORT).show() + finish() + return + } + + // Initialize views + returnActionButton = findViewById(R.id.returnActionButton) + taskTitleInput = findViewById(R.id.taskTitleInput) + taskDescriptionInput = findViewById(R.id.taskDescriptionInput) + taskStatusSpinner = findViewById(R.id.taskStatusSpinner) + createTaskButton = findViewById(R.id.createTaskButton) + + // Set default status to PENDING (index 0) + taskStatusSpinner.setSelection(0) + + // Set up button listeners + returnActionButton.setOnClickListener { + finish() + + val intent = Intent(this@TaskAddActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } + + createTaskButton.setOnClickListener { + createTask() + } + } + + private fun createTask() { + val title = taskTitleInput.text.toString().trim() + val description = taskDescriptionInput.text.toString().trim() + val status = TaskStatus.entries[taskStatusSpinner.selectedItemPosition] + + if (title.isEmpty()) { + Toast.makeText(this, "Title cannot be empty", Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + try { + Log.d("TaskAddActivity", "Creating task: $title") + val taskBase = TaskBase( + id = 0, // ID will be assigned by the server + title = title, + description = description, + status = status + ) + + val response = api.createTask(projectId, taskBase) + + if (response.isSuccessful) { + Log.d("TaskAddActivity", "Task created successfully") + Toast.makeText( + this@TaskAddActivity, + "Task created successfully", + Toast.LENGTH_SHORT + ).show() + setResult(RESULT_OK) + finish() + + // Reopen ProjectDetailActivity to show the new task + val intent = Intent(this@TaskAddActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("TaskAddActivity", "Error creating task: $errorBody") + Toast.makeText( + this@TaskAddActivity, + "Error creating task: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("TaskAddActivity", "Exception creating task: ${e.message}") + Toast.makeText( + this@TaskAddActivity, + "Failed to create task: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } +} + diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskEditActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskEditActivity.kt new file mode 100644 index 0000000..51aa741 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/TaskEditActivity.kt @@ -0,0 +1,189 @@ +package com.campusaula.edbole.kanban_clone_android.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.Spinner +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.TaskStatus +import com.campusaula.edbole.kanban_clone_android.kanban.TaskUpdate +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch + +class TaskEditActivity : AppCompatActivity() { + + private lateinit var api: ApiService + + private lateinit var returnActionButton: FloatingActionButton + private lateinit var taskTitleInput: EditText + private lateinit var taskDescriptionInput: EditText + private lateinit var taskStatusSpinner: Spinner + private lateinit var saveTaskButton: Button + private lateinit var deleteTaskButton: Button + + private var projectId: Int = -1 + private var taskId: Int = -1 + private var currentTitle: String = "" + private var currentDescription: String = "" + private var currentStatus: TaskStatus = TaskStatus.PENDING + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_task_edit) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + api = RetrofitInstance.getRetrofit(applicationContext).create(ApiService::class.java) + + // Get task and project IDs from intent + projectId = intent.getIntExtra("project_id", -1) + taskId = intent.getIntExtra("task_id", -1) + currentTitle = intent.getStringExtra("task_title") ?: "" + currentDescription = intent.getStringExtra("task_description") ?: "" + val statusString = intent.getStringExtra("task_status") ?: "PENDING" + currentStatus = TaskStatus.valueOf(statusString) + + if (projectId == -1 || taskId == -1) { + Toast.makeText(this, "Error: Invalid task or project ID", Toast.LENGTH_SHORT).show() + finish() + } + + // Initialize views + returnActionButton = findViewById(R.id.returnActionButton) + taskTitleInput = findViewById(R.id.taskTitleInput) + taskDescriptionInput = findViewById(R.id.taskDescriptionInput) + taskStatusSpinner = findViewById(R.id.taskStatusSpinner) + saveTaskButton = findViewById(R.id.saveTaskButton) + deleteTaskButton = findViewById(R.id.deleteTaskButton) + + // Populate fields with current task data + taskTitleInput.setText(currentTitle) + taskDescriptionInput.setText(currentDescription) + taskStatusSpinner.setSelection(TaskStatus.entries.indexOf(currentStatus)) + + // Set up button listeners + returnActionButton.setOnClickListener { + finish() + + val intent = Intent(this@TaskEditActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } + + saveTaskButton.setOnClickListener { + saveTask() + } + + deleteTaskButton.setOnClickListener { + deleteTask() + } + } + + private fun saveTask() { + val newTitle = taskTitleInput.text.toString().trim() + val newDescription = taskDescriptionInput.text.toString().trim() + val newStatus = TaskStatus.entries[taskStatusSpinner.selectedItemPosition] + + if (newTitle.isEmpty()) { + Toast.makeText(this, "Title cannot be empty", Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + try { + Log.d("TaskEditActivity", "Updating task: $taskId") + val taskUpdate = TaskUpdate( + title = if (newTitle != currentTitle) newTitle else null, + description = if (newDescription != currentDescription) newDescription else null, + status = if (newStatus != currentStatus) newStatus else null + ) + + val response = api.updateProjectTask(projectId, taskId, taskUpdate) + + if (response.isSuccessful) { + Log.d("TaskEditActivity", "Task updated successfully") + Toast.makeText( + this@TaskEditActivity, + "Task updated successfully", + Toast.LENGTH_SHORT + ).show() + setResult(RESULT_OK) + finish() + val intent = Intent(this@TaskEditActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("TaskEditActivity", "Error updating task: $errorBody") + Toast.makeText( + this@TaskEditActivity, + "Error updating task: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("TaskEditActivity", "Exception updating task: ${e.message}") + Toast.makeText( + this@TaskEditActivity, + "Failed to update task: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + + private fun deleteTask() { + lifecycleScope.launch { + try { + Log.d("TaskEditActivity", "Deleting task: $taskId") + val response = api.deleteProjectTask(projectId, taskId) + + if (response.isSuccessful) { + Log.d("TaskEditActivity", "Task deleted successfully") + Toast.makeText( + this@TaskEditActivity, + "Task deleted successfully", + Toast.LENGTH_SHORT + ).show() + setResult(RESULT_OK) + finish() + + // Reopen ProjectDetailActivity after deleting the task + val intent = Intent(this@TaskEditActivity, ProjectDetailActivity::class.java) + intent.putExtra("project_id", projectId) + startActivity(intent) + } else { + val errorBody = response.errorBody()?.string() + Log.e("TaskEditActivity", "Error deleting task: $errorBody") + Toast.makeText( + this@TaskEditActivity, + "Error deleting task: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("TaskEditActivity", "Exception deleting task: ${e.message}") + Toast.makeText( + this@TaskEditActivity, + "Failed to delete task: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } +} + diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectCollaboratorAdapter.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectCollaboratorAdapter.kt new file mode 100644 index 0000000..96dbde5 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectCollaboratorAdapter.kt @@ -0,0 +1,101 @@ +package com.campusaula.edbole.kanban_clone_android.ui.adapters + +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.User +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProjectCollaboratorAdapter( + private var collaborators: List, + private val apiService: ApiService, + private val projectId: Int, + private val onCollaboratorRemoved: () -> Unit = {} +) : RecyclerView.Adapter() { + + private val adapterScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun submitList(newList: List) { + collaborators = newList.toMutableList() + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_collaborator, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(collaborators[position]) + } + + override fun getItemCount(): Int = collaborators.size + + fun onDestroy() { + adapterScope.cancel("Adapter destroyed") + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val collaboratorNameText: TextView = itemView.findViewById(R.id.collaboratorNameText) + private val collaboratorEmailText: TextView = itemView.findViewById(R.id.collaboratorEmailText) + private val removeCollaboratorButton: Button = itemView.findViewById(R.id.removeCollaboratorButton) + + fun bind(collaborator: User) { + collaboratorNameText.text = collaborator.name + collaboratorEmailText.text = collaborator.email + + removeCollaboratorButton.setOnClickListener { + adapterScope.launch { + try { + Log.d("ProjectCollaboratorAdapter", "Removing collaborator: ${collaborator.id}") + val response = apiService.removeProjectCollaborator(projectId, collaborator.id) + + if (response.isSuccessful) { + Log.d("ProjectCollaboratorAdapter", "Collaborator removed successfully: ${collaborator.id}") + withContext(Dispatchers.Main) { + Toast.makeText( + itemView.context, + "Collaborator removed: ${collaborator.name}", + Toast.LENGTH_SHORT + ).show() + onCollaboratorRemoved() + } + } else { + val errorBody = response.errorBody()?.string() + Log.e("ProjectCollaboratorAdapter", "Error removing collaborator: $errorBody") + withContext(Dispatchers.Main) { + Toast.makeText( + itemView.context, + "Error removing collaborator: ${response.code()}", + Toast.LENGTH_SHORT + ).show() + } + } + } catch (e: Exception) { + Log.e("ProjectCollaboratorAdapter", "Exception removing collaborator: ${e.message}") + withContext(Dispatchers.Main) { + Toast.makeText( + itemView.context, + "Failed to remove collaborator: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectItemAdapter.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectItemAdapter.kt new file mode 100644 index 0000000..b2b3219 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectItemAdapter.kt @@ -0,0 +1,44 @@ +package com.campusaula.edbole.kanban_clone_android.ui.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.Project + +class ProjectItemAdapter( + private var items: List, + private val onItemClick: ((Project) -> Unit)? = null +) : RecyclerView.Adapter() { + + fun submitList(newList: List) { + items = newList + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_project, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val project = items[position] + holder.bind(project) + holder.itemView.setOnClickListener { onItemClick?.invoke(project) } + } + + override fun getItemCount(): Int = items.size + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val nameTextView: TextView = itemView.findViewById(R.id.projectName) + private val descriptionTextView: TextView = itemView.findViewById(R.id.projectDescription) + + fun bind(project: Project) { + nameTextView.text = project.name + descriptionTextView.text = project.description + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectTaskAdapter.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectTaskAdapter.kt new file mode 100644 index 0000000..ddb16db --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/ui/adapters/ProjectTaskAdapter.kt @@ -0,0 +1,140 @@ +package com.campusaula.edbole.kanban_clone_android.ui.adapters + +import android.content.Intent +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.Button +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.campusaula.edbole.kanban_clone_android.R +import com.campusaula.edbole.kanban_clone_android.kanban.Task +import com.campusaula.edbole.kanban_clone_android.kanban.TaskStatus +import com.campusaula.edbole.kanban_clone_android.kanban.TaskUpdate +import com.campusaula.edbole.kanban_clone_android.network.ApiService +import com.campusaula.edbole.kanban_clone_android.ui.TaskEditActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProjectTaskAdapter( + private var tasks: List, + private val apiService: ApiService, + private val projectId: Int, + private val onTaskUpdated: () -> Unit = {}, + private val onEditTask: () -> Unit = {} +) : RecyclerView.Adapter() { + + private val adapterScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_task, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(tasks[position]) + } + + override fun getItemCount(): Int = tasks.size + + fun submitList(newList: List) { + tasks = newList + notifyDataSetChanged() + } + + // Cancelar el CoroutineScope cuando el adaptador ya no sea necesario + fun onDestroy() { + adapterScope.cancel() + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val taskTitleText: TextView = itemView.findViewById(R.id.taskTitleText) + private val taskDescriptionText: TextView = itemView.findViewById(R.id.taskDescriptionText) + private val editTaskButton: Button = itemView.findViewById(R.id.editTaskButton) + private val taskStatusPicker: Spinner = itemView.findViewById(R.id.taskStatusDropdown) + + fun bind(task: Task) { + taskTitleText.text = task.title + taskDescriptionText.text = task.description + + editTaskButton.setOnClickListener { + val intent = Intent(itemView.context, TaskEditActivity::class.java) + intent.putExtra("project_id", projectId) + intent.putExtra("task_id", task.id) + intent.putExtra("task_title", task.title) + intent.putExtra("task_description", task.description) + intent.putExtra("task_status", task.status.name) + itemView.context.startActivity(intent) + onEditTask() // Cerrar la actividad + } + + taskStatusPicker.setSelection(TaskStatus.entries.indexOf(task.status)) + + taskStatusPicker.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long + ) { + val selectedStatus: TaskStatus = TaskStatus.entries[position] + task.status = selectedStatus + + adapterScope.launch { + try { + Log.d("ProjectTaskAdapter", "Sending PUT request for task: ${task.id}") + Log.d("ProjectTaskAdapter", "Project ID: $projectId") + Log.d("ProjectTaskAdapter", "TaskUpdate data: ${TaskUpdate(null, null, task.status)}") + + val response = apiService.updateProjectTask( + projectId, + task.id, + TaskUpdate(null, null, task.status) + ) + + if (response.isSuccessful) { + Log.d("ProjectTaskAdapter", "Task updated successfully: ${task.id}") + withContext(Dispatchers.Main) { + onTaskUpdated() + notifyDataSetChanged() + } + } else { + val errorBody = response.errorBody()?.string() + Log.e("ProjectTaskAdapter", "Error response: $errorBody") + withContext(Dispatchers.Main) { + Toast.makeText( + itemView.context, + "Error updating task: ${response.code()} - $errorBody", + Toast.LENGTH_SHORT + ).show() + } + } + } catch (e: Exception) { + Log.e("ProjectTaskAdapter", "Exception updating task: ${e.message}") + withContext(Dispatchers.Main) { + Toast.makeText( + itemView.context, + "Failed to update task: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // No action needed + } + } + + } + } +} diff --git a/app/src/main/res/layout/activity_collaborator_add.xml b/app/src/main/res/layout/activity_collaborator_add.xml new file mode 100644 index 0000000..b31ddce --- /dev/null +++ b/app/src/main/res/layout/activity_collaborator_add.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + +