List commits in a repository

main
Clément FRÉVILLE 2 years ago
parent 1f85bf2f71
commit 8f503b64a4

@ -36,6 +36,9 @@ android {
} }
dependencies { dependencies {
def nav_version = "2.5.3"
def paging_version = "3.1.1"
def fragment_version = "1.5.6"
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
@ -45,8 +48,15 @@ dependencies {
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'com.squareup.moshi:moshi:1.14.0' implementation 'com.squareup.moshi:moshi:1.14.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.14.0' implementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
implementation 'com.squareup.moshi:moshi-adapters:1.14.0'
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.paging:paging-runtime:$paging_version"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
//implementation 'androidx.core:core-ktx:+'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

@ -3,12 +3,13 @@ package fr.uca.iut.clfreville2.teaiswarm
import android.content.Intent import android.content.Intent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle import android.os.Bundle
import android.widget.Button
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.uca.iut.clfreville2.teaiswarm.model.Repository import fr.uca.iut.clfreville2.teaiswarm.model.Repository
import fr.uca.iut.clfreville2.teaiswarm.network.GiteaService import fr.uca.iut.clfreville2.teaiswarm.network.GiteaService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.max
const val REPOSITORY_OWNER = "repository_owner" const val REPOSITORY_OWNER = "repository_owner"
const val REPOSITORY_NAME = "repository_name" const val REPOSITORY_NAME = "repository_name"
@ -17,18 +18,24 @@ class MainActivity : AppCompatActivity() {
private val service = GiteaService() private val service = GiteaService()
private lateinit var repositories: RecyclerView private lateinit var repositories: RecyclerView
private lateinit var previousButton: Button
private lateinit var nextButton: Button
private var currentPage = 1
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
repositories = findViewById(R.id.repositories_view) repositories = findViewById(R.id.repositories_view)
lifecycleScope.launch(Dispatchers.IO) { previousButton = findViewById(R.id.previous_repository_list)
val repos = service.listActiveRepositories("clement.freville2", 1) nextButton = findViewById(R.id.next_repository_list)
lifecycleScope.launch { updateList()
repositories.adapter = RepositoryListAdapter(repos) { repo -> previousButton.setOnClickListener {
adapterOnClick(repo) currentPage = max(currentPage - 1, 0)
} updateList()
} }
nextButton.setOnClickListener {
currentPage += 1
updateList()
} }
} }
@ -38,4 +45,13 @@ class MainActivity : AppCompatActivity() {
intent.putExtra(REPOSITORY_NAME, repository.name) intent.putExtra(REPOSITORY_NAME, repository.name)
startActivity(intent) startActivity(intent)
} }
private fun updateList() {
lifecycleScope.launch {
val repos = service.listActiveRepositories("clement.freville2", currentPage)
repositories.adapter = RepositoryListAdapter(repos) { repo ->
adapterOnClick(repo)
}
}
}
} }

@ -1,45 +1,57 @@
package fr.uca.iut.clfreville2.teaiswarm package fr.uca.iut.clfreville2.teaiswarm
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifier import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifier
import fr.uca.iut.clfreville2.teaiswarm.model.VersionedFile
import fr.uca.iut.clfreville2.teaiswarm.network.GiteaService import fr.uca.iut.clfreville2.teaiswarm.network.GiteaService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
const val FILE_PATH = "file_path"
class RepositoryDetailActivity : AppCompatActivity() { class RepositoryDetailActivity : AppCompatActivity() {
private val service = GiteaService() private val service = GiteaService()
private lateinit var repositoryName: TextView private lateinit var repositoryName: TextView
private lateinit var versionedFiles: RecyclerView private lateinit var versionedFiles: RecyclerView
private lateinit var currentRepositoryOwner: String
private lateinit var currentRepositoryName: String
private var currentFilePath: String? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_repository_detail) setContentView(R.layout.activity_repository_detail)
repositoryName = findViewById(R.id.repository_detail_name) repositoryName = findViewById(R.id.repository_detail_name)
var currentRepositoryOwner: String? = null
var currentRepositoryName: String? = null
val bundle: Bundle? = intent.extras val bundle: Bundle? = intent.extras
if (bundle != null) { if (bundle != null) {
currentRepositoryOwner = bundle.getString(REPOSITORY_OWNER) currentRepositoryOwner = bundle.getString(REPOSITORY_OWNER)!!
currentRepositoryName = bundle.getString(REPOSITORY_NAME) currentRepositoryName = bundle.getString(REPOSITORY_NAME)!!
currentFilePath = bundle.getString(FILE_PATH)
} }
currentRepositoryName?.let { repositoryName.text = currentRepositoryName
repositoryName.text = currentRepositoryName
}
versionedFiles = findViewById(R.id.versioned_files_view) versionedFiles = findViewById(R.id.versioned_files_view)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch {
val repos = service.listFileContents(RepositoryIdentifier(currentRepositoryOwner!!, currentRepositoryName!!), "") val repos = service.listFileContents(RepositoryIdentifier(currentRepositoryOwner, currentRepositoryName), currentFilePath ?: "")
lifecycleScope.launch { versionedFiles.adapter = FileListAdapter(repos) {
versionedFiles.adapter = FileListAdapter(repos) {} file -> adapterOnClick(file)
} }
} }
} }
private fun adapterOnClick(file: VersionedFile) {
val intent = Intent(this, RepositoryDetailActivity()::class.java)
intent.putExtra(REPOSITORY_OWNER, currentRepositoryOwner)
intent.putExtra(REPOSITORY_NAME, currentRepositoryName)
intent.putExtra(FILE_PATH, (currentFilePath ?: "") + file.name)
startActivity(intent)
}
} }

@ -0,0 +1,149 @@
package fr.uca.iut.clfreville2.teaiswarm.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.*
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.uca.iut.clfreville2.teaiswarm.R
import fr.uca.iut.clfreville2.teaiswarm.model.CommitActivity
import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifier
import fr.uca.iut.clfreville2.teaiswarm.network.GiteaService
import fr.uca.iut.clfreville2.teaiswarm.network.RepositoryService
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
class ActivityListFragment : Fragment(R.layout.activity_list) {
private val service = GiteaService()
var repository: RepositoryIdentifier? = null
var sha: String? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateCommits()
}
private fun updateCommits() {
val viewModel by viewModels<ActivityViewModel>(
factoryProducer = {
ActivityViewModelFactory(
service,
repository!!,
sha
)
}
)
val pagingAdapter = ActivityAdapter(ActivityComparator)
val recyclerView = requireView().findViewById<RecyclerView>(R.id.activity_list_view)
recyclerView.adapter = pagingAdapter
recyclerView.layoutManager = LinearLayoutManager(requireContext())
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
pagingAdapter.submitData(pagingData)
}
}
}
class ActivitySource(
private val service: RepositoryService,
private val repository: RepositoryIdentifier,
private val sha: String?
) : PagingSource<Int, CommitActivity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommitActivity> =
try {
val nextPageNumber = params.key ?: 1
val response = service.listCommits(repository, sha, nextPageNumber)
LoadResult.Page(
data = response,
prevKey = nextPageNumber - 1,
nextKey = nextPageNumber + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
override fun getRefreshKey(state: PagingState<Int, CommitActivity>): Int? =
state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
class ActivityAdapter(diffCallback: DiffUtil.ItemCallback<CommitActivity>) :
PagingDataAdapter<CommitActivity, ViewHolder>(diffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.activity_row_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
holder.bind(getItem(position))
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val commitNameView: TextView
init {
commitNameView = view.findViewById(R.id.commit_name)
}
fun bind(commit: CommitActivity?) {
commit?.let {
commitNameView.text = it.commit.message
}
}
}
object ActivityComparator : DiffUtil.ItemCallback<CommitActivity>() {
override fun areItemsTheSame(oldItem: CommitActivity, newItem: CommitActivity): Boolean =
oldItem.sha == newItem.sha
override fun areContentsTheSame(oldItem: CommitActivity, newItem: CommitActivity): Boolean =
oldItem == newItem
}
class ActivityViewModel(
private val service: RepositoryService,
private val repository: RepositoryIdentifier,
private val sha: String?
) : ViewModel() {
val flow = Pager(
PagingConfig(pageSize = 10, enablePlaceholders = true)
) {
ActivitySource(service, repository, sha)
}.flow
}
class ActivityViewModelFactory(
private val service: RepositoryService,
private val repository: RepositoryIdentifier,
private val sha: String?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ActivityViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return ActivityViewModel(service, repository, sha) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}

@ -0,0 +1,5 @@
package fr.uca.iut.clfreville2.teaiswarm.model
import java.util.Date
data class Author(val name: String, val email: String, val date: Date)

@ -0,0 +1,3 @@
package fr.uca.iut.clfreville2.teaiswarm.model
data class CommitActivity(val sha: String, val commit: Commit, val author: Owner?, val committer: Owner?)

@ -0,0 +1,14 @@
package fr.uca.iut.clfreville2.teaiswarm.model
import com.squareup.moshi.Json
enum class FileType {
@Json(name = "file")
FILE,
@Json(name = "dir")
DIR,
@Json(name = "symlink")
SYMLINK,
@Json(name = "submodule")
SUBMODULE
}

@ -0,0 +1,3 @@
package fr.uca.iut.clfreville2.teaiswarm.model
data class Commit(val author: Author, val committer: Author, val message: String)

@ -0,0 +1,3 @@
package fr.uca.iut.clfreville2.teaiswarm.model
data class Verification(val verified: Boolean)

@ -1,3 +1,3 @@
package fr.uca.iut.clfreville2.teaiswarm.model package fr.uca.iut.clfreville2.teaiswarm.model
data class VersionedFile(val name: String) data class VersionedFile(val name: String, val type: FileType)

@ -1,23 +1,29 @@
package fr.uca.iut.clfreville2.teaiswarm.network package fr.uca.iut.clfreville2.teaiswarm.network
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.EnumJsonAdapter
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import fr.uca.iut.clfreville2.teaiswarm.model.*
import fr.uca.iut.clfreville2.teaiswarm.model.Repository import kotlinx.coroutines.Dispatchers
import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifiable import kotlinx.coroutines.withContext
import fr.uca.iut.clfreville2.teaiswarm.model.VersionedFile
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import java.util.*
interface GiteaApiService { interface GiteaApiService {
@GET("users/{username}/repos") @GET("users/{username}/repos")
suspend fun listActiveRepositories(@Path("username") username: String, @Query("page") page: Int): List<Repository> suspend fun listActiveRepositories(@Path("username") username: String, @Query("page") page: Int): List<Repository>
@GET("repos/{owner}/{repo}/commits")
suspend fun listCommits(@Path("owner") owner: String, @Path("repo") repo: String, @Query("sha") sha: String?, @Query("page") page: Int): List<CommitActivity>
@GET("repos/{owner}/{repo}/contents/{filePath}") @GET("repos/{owner}/{repo}/contents/{filePath}")
suspend fun listFileContents(@Path("owner") owner: String, @Path("repo") repo: String, @Path("filePath") filePath: String): List<VersionedFile> suspend fun listFileContents(@Path("owner") owner: String, @Path("repo") repo: String, @Path("filePath") filePath: String): List<VersionedFile>
} }
@ -26,11 +32,21 @@ class GiteaService(private val handle: GiteaApiService) : RepositoryService {
constructor() : this(createRetrofit().create(GiteaApiService::class.java)) constructor() : this(createRetrofit().create(GiteaApiService::class.java))
override suspend fun listActiveRepositories(username: String, page: Int): List<Repository> = override suspend fun listActiveRepositories(username: String, page: Int): List<Repository> = withContext(Dispatchers.IO) {
handle.listActiveRepositories(username, page) handle.listActiveRepositories(username, page)
}
override suspend fun listCommits(
repository: RepositoryIdentifiable,
sha: String?,
page: Int
): List<CommitActivity> = withContext(Dispatchers.IO) {
handle.listCommits(repository.identifier.owner, repository.identifier.name, sha, page)
}
override suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String): List<VersionedFile> = override suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String): List<VersionedFile> = withContext(Dispatchers.IO) {
handle.listFileContents(repository.identifier.owner, repository.identifier.name, filePath) handle.listFileContents(repository.identifier.owner, repository.identifier.name, filePath)
}
} }
private const val CODEFIRST_API_BASE = "https://codefirst.iut.uca.fr/git/api/v1/" private const val CODEFIRST_API_BASE = "https://codefirst.iut.uca.fr/git/api/v1/"
@ -42,6 +58,7 @@ private fun createRetrofit(): Retrofit =
.baseUrl(CODEFIRST_API_BASE) .baseUrl(CODEFIRST_API_BASE)
.addConverterFactory(MoshiConverterFactory.create( .addConverterFactory(MoshiConverterFactory.create(
Moshi.Builder() Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter().nullSafe())
.add(KotlinJsonAdapterFactory()) .add(KotlinJsonAdapterFactory())
.build())) .build()))
.client(httpClient) .client(httpClient)

@ -1,12 +1,12 @@
package fr.uca.iut.clfreville2.teaiswarm.network package fr.uca.iut.clfreville2.teaiswarm.network
import fr.uca.iut.clfreville2.teaiswarm.model.Repository import fr.uca.iut.clfreville2.teaiswarm.model.*
import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifiable
import fr.uca.iut.clfreville2.teaiswarm.model.VersionedFile
interface RepositoryService { interface RepositoryService {
suspend fun listActiveRepositories(username: String, page: Int): List<Repository> suspend fun listActiveRepositories(username: String, page: Int): List<Repository>
suspend fun listCommits(repository: RepositoryIdentifiable, sha: String?, page: Int): List<CommitActivity>
suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String): List<VersionedFile> suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String): List<VersionedFile>
} }

@ -1,9 +1,8 @@
package fr.uca.iut.clfreville2.teaiswarm.network package fr.uca.iut.clfreville2.teaiswarm.network
import fr.uca.iut.clfreville2.teaiswarm.model.Owner import fr.uca.iut.clfreville2.teaiswarm.model.*
import fr.uca.iut.clfreville2.teaiswarm.model.Repository import java.util.Date
import fr.uca.iut.clfreville2.teaiswarm.model.RepositoryIdentifiable import kotlin.random.Random
import fr.uca.iut.clfreville2.teaiswarm.model.VersionedFile
class StubRepositoryService : RepositoryService { class StubRepositoryService : RepositoryService {
@ -28,6 +27,22 @@ class StubRepositoryService : RepositoryService {
else -> listOf() else -> listOf()
}.map { Repository(Owner(-1, ""), it) } }.map { Repository(Owner(-1, ""), it) }
override suspend fun listCommits(
repository: RepositoryIdentifiable,
sha: String?,
page: Int
): List<CommitActivity> {
val author = Author("clement.freville2", "clement.freville2@etu.uca.fr", Date())
return (0..10).map {
CommitActivity(
randomCommitSha(),
Commit(author, author, "Implement parser"),
null,
null
)
}
}
override suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String) = override suspend fun listFileContents(repository: RepositoryIdentifiable, filePath: String) =
listOf( listOf(
"cli", "cli",
@ -39,5 +54,10 @@ class StubRepositoryService : RepositoryService {
".gitignore", ".gitignore",
"CONVENTIONS.md", "CONVENTIONS.md",
"README.md" "README.md"
).map { VersionedFile(it) } ).map { VersionedFile(it, if (it.contains(".")) { FileType.FILE } else { FileType.DIR }) }
} }
val CHAR_POOL = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
fun randomCommitSha() = (1..40)
.map { Random.nextInt(0, CHAR_POOL.size).let { CHAR_POOL[it] } }
.joinToString("")

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/activity_list_view" />
</FrameLayout>

@ -12,4 +12,21 @@
android:layout_height="match_parent" android:layout_height="match_parent"
app:layoutManager="LinearLayoutManager"/> app:layoutManager="LinearLayoutManager"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/previous_repository_list"
android:text="@string/previous" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/next_repository_list"
android:text="@string/next" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/commit_name" />
</LinearLayout>

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<TextView <TextView
@ -8,4 +8,4 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/repository_name" /> android:text="@string/repository_name" />
</FrameLayout> </LinearLayout>

@ -2,4 +2,10 @@
<string name="app_name">TeaIsWarm</string> <string name="app_name">TeaIsWarm</string>
<string name="repository_name">Repository name</string> <string name="repository_name">Repository name</string>
<string name="repository_description">Repository description</string> <string name="repository_description">Repository description</string>
<plurals name="stars">
<item quantity="one">%d star</item>
<item quantity="other">%d stars</item>
</plurals>
<string name="previous">Previous</string>
<string name="next">Next</string>
</resources> </resources>
Loading…
Cancel
Save