diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 54e4eac..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -android { - compileSdk 34 - namespace "otus.gpb.recyclerview" - - defaultConfig { - applicationId "otus.gpb.recyclerview" - minSdk 26 - targetSdk 34 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } -} - -dependencies { - - implementation 'androidx.core:core-ktx:1.7.0' - implementation 'androidx.appcompat:appcompat:1.5.1' - implementation 'com.google.android.material:material:1.7.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..712fa8e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,56 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + namespace = "otus.gpb.recyclerview" + compileSdk = 36 + + defaultConfig { + applicationId = "otus.gpb.recyclerview" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + viewBinding = true + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.recyclerview) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef75335..048f327 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ , + private val itemListener: ItemListener, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ChatViewHolder { + val chatItemBinding = + ChatItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ChatViewHolder(chatItemBinding, itemListener) + } + + override fun onBindViewHolder( + holder: ChatViewHolder, + position: Int + ) { + holder.bind(dataSet[position]) + } + + override fun getItemCount() = dataSet.size +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatDiffAdapter.kt b/app/src/main/java/otus/gpb/recyclerview/ChatDiffAdapter.kt new file mode 100644 index 0000000..97efc32 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatDiffAdapter.kt @@ -0,0 +1,37 @@ +package otus.gpb.recyclerview + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import otus.gpb.recyclerview.databinding.ChatItemBinding +import otus.gpb.recyclerview.model.ChatItem + +class ChatDiffAdapter( + private val itemListener: ItemListener, +) : ListAdapter(DiffUtilItem()) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ChatViewHolder { + val chatItemBinding = + ChatItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ChatViewHolder(chatItemBinding, itemListener) + } + + override fun onBindViewHolder(holder: ChatViewHolder, position: Int) = + holder.bind(getItem(position)) + +} + +private class DiffUtilItem : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + return oldItem::class == newItem::class && oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { + return oldItem == newItem + } + +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatItemTouchHelper.kt b/app/src/main/java/otus/gpb/recyclerview/ChatItemTouchHelper.kt new file mode 100644 index 0000000..7b45f45 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatItemTouchHelper.kt @@ -0,0 +1,121 @@ +package otus.gpb.recyclerview + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.VectorDrawable +import android.util.DisplayMetrics +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import java.util.UUID +import kotlin.math.roundToInt + + +class ChatItemTouchHelper(private val listener: ItemListener) : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.ACTION_STATE_IDLE, + ItemTouchHelper.LEFT +) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ) = false + + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + viewHolder.getElementId()?.let { listener.onSwipe(it) } + } + + /** + * source: https://stackoverflow.com/questions/30820806/adding-a-colored-background-with-text-icon-under-swiped-row-when-using-androids + * Источник из ДЗ (не загружается без vpn) https://www.digitalocean.com/community/tutorials/android-recyclerview-swipe-to-delete-undo + * */ + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + + // Get RecyclerView item from the ViewHolder + val itemView = viewHolder.itemView + + val p = Paint() + val context = itemView.context + val resources: Resources = context.resources + + if (dX < 0) { + /* Set your color for negative displacement */ + p.setColor(ContextCompat.getColor(context, R.color.light_blue)) + // Draw Rect with varying left side, equal to the item's right side plus negative displacement dX + + val itemViewTop = itemView.top.toFloat() + val itemViewRight = itemView.right.toFloat() + val itemViewBottom = itemView.bottom.toFloat() + c.drawRect( + /* left = */ itemViewRight + dX, + /* top = */ itemViewTop, + /* right = */ itemViewRight, + /* bottom = */ itemViewBottom, + /* paint = */ p + ) + +// fixme magic numbers to dimensions, add relative counters + getVectorBitmap(context, R.drawable.archive)?.let { bitmap -> + val left = itemViewRight - convertDpToPx(50, resources) - bitmap.getWidth() + val top = itemViewTop + (itemViewBottom - itemViewTop - bitmap.getHeight()) * 0.4f + c.drawBitmap( + /* bitmap = */ bitmap, + /* left = */ left, + /* top = */ top, + /* paint = */ p + ) + + c.drawText( + "Archive", + left - convertDpToPx(30, resources), + top + convertDpToPx(50, resources), + Paint().apply { + color = ContextCompat.getColor(context, R.color.white) + textSize = 36f + } + ) + } + } + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } + + /** + * source https://stackoverflow.com/questions/44612353/using-vector-drawable-by-drawing-in-canvas + * */ + private fun getVectorBitmap(context: Context, drawableId: Int) = + ContextCompat.getDrawable(context, drawableId)?.asClass() + ?.let { drawable -> + val bitmap: Bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } + + private fun convertDpToPx(dp: Int, resources: Resources): Int { + return (dp * (resources.displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + } + + private fun RecyclerView.ViewHolder.getElementId(): UUID? { + return bindingAdapter + ?.asClass() + ?.currentList + ?.getOrNull(bindingAdapterPosition)?.id + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ChatViewHolder.kt b/app/src/main/java/otus/gpb/recyclerview/ChatViewHolder.kt new file mode 100644 index 0000000..21f04be --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ChatViewHolder.kt @@ -0,0 +1,49 @@ +package otus.gpb.recyclerview + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import otus.gpb.recyclerview.databinding.ChatItemBinding +import otus.gpb.recyclerview.model.ChatItem + +class ChatViewHolder( + private val cartItemBinding: ChatItemBinding, + private val itemListener: ItemListener +) : RecyclerView.ViewHolder(cartItemBinding.root) { + + val chatImage: ImageView = cartItemBinding.chatImage + val chatNameText: TextView = cartItemBinding.chatName + val lastUserNameText: TextView = cartItemBinding.lastUserName + val lastMessageText: TextView = cartItemBinding.lastMessage + val verifiedImage: ImageView = cartItemBinding.verified + val muteImage: ImageView = cartItemBinding.mute + val messageStatusImage: ImageView = cartItemBinding.messageStatus + val lastMessageTimeText: TextView = cartItemBinding.lastMessageTime + val chatPinnedImage: ImageView = cartItemBinding.pinned + + + + fun bind(chatItem: ChatItem) { + with(chatItem) { + chatImage.setImageResource(imageId) + chatNameText.text = chatName + lastUserNameText.text = lastUserName + lastMessageText.text = lastMessage + verifiedImage.visibility = if (isVerified) View.VISIBLE else View.GONE + muteImage.visibility = if (isMuted) View.VISIBLE else View.GONE + + messageStatusImage.setImageResource(messageStatus.iconId) + messageStatusImage.setColorFilter(ContextCompat.getColor(cartItemBinding.root.context, messageStatus.colorId), + android.graphics.PorterDuff.Mode.SRC_IN) + lastMessageTimeText.text = lastMessageViewTime + chatPinnedImage.visibility = if (isPinned) View.VISIBLE else View.GONE + cartItemBinding.root.setOnClickListener { + itemListener.onItemClick(id) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/ItemListener.kt b/app/src/main/java/otus/gpb/recyclerview/ItemListener.kt new file mode 100644 index 0000000..dd5bc2f --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/ItemListener.kt @@ -0,0 +1,9 @@ +package otus.gpb.recyclerview + +import java.util.UUID + +interface ItemListener { + fun onItemClick(id: UUID) + fun onSwipe(id: UUID) + +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt b/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt index e2cdca7..5fb4b45 100644 --- a/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt +++ b/app/src/main/java/otus/gpb/recyclerview/MainActivity.kt @@ -1,12 +1,79 @@ package otus.gpb.recyclerview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import otus.gpb.recyclerview.databinding.ActivityMainBinding +import otus.gpb.recyclerview.repository.ChatRepository +import otus.gpb.recyclerview.stat.PageEventHelper +import otus.gpb.recyclerview.view_model.ChatViewModel +import java.util.UUID + +class MainActivity : AppCompatActivity(), ItemListener { + + lateinit var activityMainBinding: ActivityMainBinding + + private val chatDiffAdapter: ChatDiffAdapter by lazy { ChatDiffAdapter(this) } + + private val chatRepository: ChatRepository by lazy { application.asClass()?.chatRepository + ?: throw IllegalStateException("Can`t get App class") } + private val viewModel: ChatViewModel by viewModels { + ChatViewModel.Factory(chatRepository) + } -class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + activityMainBinding = ActivityMainBinding.inflate(layoutInflater) + setContentView(activityMainBinding.root) + initRecyclerView() + addObservers() } -} \ No newline at end of file + + private fun addObservers() { + application.asClass()?.let { + lifecycle.addObserver(PageEventHelper(it.statService)) + } + } + + private fun initRecyclerView() { + val recyclerView = activityMainBinding.chatListView + with(recyclerView) { + addItemDecoration(DividerItemDecoration(this@MainActivity, LinearLayout.VERTICAL)) + ItemTouchHelper(ChatItemTouchHelper(this@MainActivity)).attachToRecyclerView(this) + adapter = chatDiffAdapter + viewModel.content.observe(this@MainActivity) { + chatDiffAdapter.submitList(it) + } + addOnScrollListener(OnScrollListener { viewModel.loadNextPage() }) + } + } + + class OnScrollListener(private val action: () -> Unit) : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged( + recyclerView: RecyclerView, + newState: Int + ) { + super.onScrollStateChanged(recyclerView, newState) + if (!recyclerView.canScrollVertically(1)) { + action.invoke() + } + } + } + + override fun onItemClick(id: UUID) { + Toast.makeText(this, "Item clicked: $id", Toast.LENGTH_SHORT).show() + } + + override fun onSwipe(id: UUID) { + viewModel.removeItem(id) + Toast.makeText(this, "Item swiped: $id", Toast.LENGTH_SHORT).show() + } + +} + diff --git a/app/src/main/java/otus/gpb/recyclerview/Utils.kt b/app/src/main/java/otus/gpb/recyclerview/Utils.kt new file mode 100644 index 0000000..8d219cd --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/Utils.kt @@ -0,0 +1,3 @@ +package otus.gpb.recyclerview + +fun Any.asClass(): T? = if (this as? T == null) null else this \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/model/ChatItem.kt b/app/src/main/java/otus/gpb/recyclerview/model/ChatItem.kt new file mode 100644 index 0000000..a56e12d --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/model/ChatItem.kt @@ -0,0 +1,40 @@ +package otus.gpb.recyclerview.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import otus.gpb.recyclerview.R +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +private val formatter = DateTimeFormatter.ofPattern("d MMM HH:mm") + +data class ChatItem( + val id: UUID, + val imageId: Int, + val chatName: String, + val lastUserName: String, + val lastMessage: String, + val title: String, + var isVerified: Boolean, + val isMuted: Boolean, + val messageStatus: MessageStatus, + var lastMessageTime: LocalDateTime, + var lastMessageViewTime: String = lastMessageTime.format(formatter) + .toString(), + val isPinned: Boolean +) { + fun setTime(time: LocalDateTime) { + lastMessageTime = time + lastMessageViewTime = time.format(formatter).toString() + } +} + +enum class MessageStatus( + @param:ColorRes val colorId: Int, + @param:DrawableRes val iconId: Int +) { + SENT(R.color.grey, R.drawable.check), + DELIVERED(R.color.grey, R.drawable.read), + READ(R.color.green, R.drawable.read);// @ColorRes colorId:Int, +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/prefs/KeyValueStorage.kt b/app/src/main/java/otus/gpb/recyclerview/prefs/KeyValueStorage.kt new file mode 100644 index 0000000..6d9b0c8 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/prefs/KeyValueStorage.kt @@ -0,0 +1,67 @@ +package otus.gpb.recyclerview.prefs + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.lifecycle.LiveData + +/** + * Key-value storage interface + */ +interface KeyValueStorage { + /** + * Gets value by key + */ + operator fun get(key: String): String? + + /** + * Sets value by key + */ + operator fun set(key: String, value: String?) + + /** + * Gets value by key as live data + */ + fun liveData(key: String): LiveData + + /** + * Clears all keys + */ + fun clear() +} + +/** + * Uses shared preferences as a storage + */ +class SharedPreferencesStorage(context: Context, name: String) : KeyValueStorage { + private val prefs = context.getSharedPreferences(name, Context.MODE_PRIVATE) + + override fun get(key: String): String? = prefs.getString(key, null) + + override fun set(key: String, value: String?) { + prefs.edit { putString(key, value) } + } + + override fun liveData(key: String): LiveData = object : LiveData(get(key)) { + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, changedKey -> + if (changedKey == key) { + postValue(get(key)) + } + } + + override fun onActive() { + super.onActive() + postValue(get(key)) + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onInactive() { + super.onInactive() + prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + } + + override fun clear() { + prefs.edit { clear() } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/repository/ChatRepository.kt b/app/src/main/java/otus/gpb/recyclerview/repository/ChatRepository.kt new file mode 100644 index 0000000..4559ab9 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/repository/ChatRepository.kt @@ -0,0 +1,92 @@ +package otus.gpb.recyclerview.repository + +import otus.gpb.recyclerview.R +import otus.gpb.recyclerview.model.ChatItem +import otus.gpb.recyclerview.model.MessageStatus +import java.time.LocalDateTime +import java.util.Random +import java.util.UUID + +class ChatRepository() { + + private val random = Random() + val pageSize = 10 + private var page = 1 + private var testData: MutableList + + private val listeners: MutableList = mutableListOf() + + fun subscribe(listener: ChatServerListener) { + listeners.add(listener) + } + + init { + testData = generateTestData() + + // имитация подгрузки данных через с сервера через socket + object : Thread() { + override fun run() { + while (true) { + sleep(10_000) + val item = testData[random.nextInt(testData.size)] + item.setTime(LocalDateTime.now()) + item.isVerified = false + testData = + testData.toMutableList().apply { sortByDescending { it.lastMessageTime } } + notifyListeners() + } + } + }.start() + + } + + private fun notifyListeners() { + listeners.forEach { it.onChatItemUpdated(getDataPage()) } + } + + fun loadNextItems() { + page++ + notifyListeners() + } + + fun removeItem(id: UUID) { + testData = testData.filter { it.id != id } + .toMutableList() // удаляем элемент по id и возвращаем новый список testData.removeIf { it.id != id } + notifyListeners() + } + + fun getDataPage(): MutableList { + val toIndex = (page * pageSize).coerceAtMost(testData.size) // не выйти за пределы + return if (toIndex in 0 until testData.size) { + testData.subList(0, toIndex) + } else { + testData.toMutableList() + } + } + + private fun generateTestData(): MutableList { + val now = LocalDateTime.now() + return Array(25) { index: Int -> + val time: LocalDateTime = now.minusSeconds(random.nextInt(3600 * 24 * 7).toLong()) + ChatItem( + id = UUID.randomUUID(), + imageId = R.drawable.chat_item_icon, + chatName = "Chat Name " + index, + lastUserName = "User Name " + index, + lastMessage = "Last Message " + index, + title = "Title " + index, + isVerified = random.nextBoolean(), + isMuted = random.nextBoolean(), + messageStatus = MessageStatus.entries[random.nextInt(3)], + lastMessageTime = time, + isPinned = random.nextBoolean() + ) + } + .apply { sortByDescending { it.lastMessageTime } } + .toMutableList() + } +} + +interface ChatServerListener { + fun onChatItemUpdated(testData: MutableList) +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/stat/Event.kt b/app/src/main/java/otus/gpb/recyclerview/stat/Event.kt new file mode 100644 index 0000000..12e570d --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/stat/Event.kt @@ -0,0 +1,9 @@ +package otus.gpb.recyclerview.stat + +/** + * Event + */ +interface Event { + val name: String + val properties: Map +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/stat/PageEvent.kt b/app/src/main/java/otus/gpb/recyclerview/stat/PageEvent.kt new file mode 100644 index 0000000..5509984 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/stat/PageEvent.kt @@ -0,0 +1,39 @@ +package otus.gpb.recyclerview.stat + +import androidx.annotation.VisibleForTesting +import java.time.Instant +import kotlin.time.ExperimentalTime + +data class PageEvent @OptIn(ExperimentalTime::class) constructor( + private val page: String, + private val action: String, + private val time: Instant +) : Event { + override val name: String get() = PAGE + + override val properties: Map + get() = mapOf( + NAME to page, + TIME to time.toString(), + ACTION to action + ) + + @VisibleForTesting + companion object { + const val PAGE = "page" + const val NAME = "name" + const val TIME = "time" + const val ACTION = "action" + } +} + +fun createPageEvent( + name: String, + action: String, + time: Instant = Instant.now() +) = PageEvent(name, action, time) + +fun T.createPageEvent( + action: String, + time: Instant = Instant.now() +) = createPageEvent(requireNotNull(this::class.simpleName), action, time) \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/stat/PageEventHelper.kt b/app/src/main/java/otus/gpb/recyclerview/stat/PageEventHelper.kt new file mode 100644 index 0000000..afd00ab --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/stat/PageEventHelper.kt @@ -0,0 +1,42 @@ +package otus.gpb.recyclerview.stat + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Helper to log page events + */ +class PageEventHelper(private val statService: StatService): DefaultLifecycleObserver { + private fun logEvent(owner: LifecycleOwner, action: String) { + statService.logEvent( + createPageEvent( + name = owner::class.simpleName ?: "Unknown", + action = action + ) + ) + } + + override fun onCreate(owner: LifecycleOwner) { + logEvent(owner, "onCreate") + } + + override fun onStart(owner: LifecycleOwner) { + logEvent(owner, "onStart") + } + + override fun onResume(owner: LifecycleOwner) { + logEvent(owner,"onResume") + } + + override fun onPause(owner: LifecycleOwner) { + logEvent(owner, "onPause") + } + + override fun onStop(owner: LifecycleOwner) { + logEvent(owner, "onStop") + } + + override fun onDestroy(owner: LifecycleOwner) { + logEvent(owner, "onDestroy") + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/stat/StatEventLogger.kt b/app/src/main/java/otus/gpb/recyclerview/stat/StatEventLogger.kt new file mode 100644 index 0000000..948bc16 --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/stat/StatEventLogger.kt @@ -0,0 +1,24 @@ +package otus.gpb.recyclerview.stat + +import android.util.Log + +/** + * Stat service + */ +interface StatService { + + /** + * Logs event + */ + fun logEvent(event: Event) + + class Logger: StatService { + override fun logEvent(event: Event) { + Log.i( TAG, "Event: ${event.name}, Properties ${event.properties}") + } + + companion object { + private const val TAG = "StatService" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/gpb/recyclerview/view_model/ChatViewModel.kt b/app/src/main/java/otus/gpb/recyclerview/view_model/ChatViewModel.kt new file mode 100644 index 0000000..2b6649a --- /dev/null +++ b/app/src/main/java/otus/gpb/recyclerview/view_model/ChatViewModel.kt @@ -0,0 +1,57 @@ +package otus.gpb.recyclerview.view_model + +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import otus.gpb.recyclerview.model.ChatItem +import otus.gpb.recyclerview.repository.ChatRepository +import otus.gpb.recyclerview.repository.ChatServerListener +import java.util.UUID + +class ChatViewModel( + private val chatRepository: ChatRepository +) : ViewModel(), ChatServerListener { + + init { + chatRepository.subscribe(this) + } + + private val _content: MutableLiveData> = + MutableLiveData(chatRepository.getDataPage()) + + val content: LiveData> get() = _content + + fun loadNextPage() { + chatRepository.loadNextItems() + } + + fun removeItem(id: UUID) { + chatRepository.removeItem(id) + } + + /** + * Подписались на информацию в репозитории + * */ + override fun onChatItemUpdated(testData: MutableList) { + _content.postValue(testData) + } + + @Suppress("UNCHECKED_CAST") + class Factory(private val chatRepository: ChatRepository) : + AbstractSavedStateViewModelFactory() { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + if (modelClass.isAssignableFrom(ChatViewModel::class.java)) { + return ChatViewModel(chatRepository) as? T + ?: throw IllegalArgumentException("Can`t create ChatViewModel by factory") + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/archive.xml b/app/src/main/res/drawable/archive.xml new file mode 100644 index 0000000..a2f1f70 --- /dev/null +++ b/app/src/main/res/drawable/archive.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/chat_item_icon.xml b/app/src/main/res/drawable/chat_item_icon.xml new file mode 100644 index 0000000..f2987f4 --- /dev/null +++ b/app/src/main/res/drawable/chat_item_icon.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/check.xml b/app/src/main/res/drawable/check.xml new file mode 100644 index 0000000..68140c3 --- /dev/null +++ b/app/src/main/res/drawable/check.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100644 index 0000000..bb3fdf0 --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mention.xml b/app/src/main/res/drawable/mention.xml new file mode 100644 index 0000000..4e54912 --- /dev/null +++ b/app/src/main/res/drawable/mention.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/menu.xml b/app/src/main/res/drawable/menu.xml new file mode 100644 index 0000000..9543621 --- /dev/null +++ b/app/src/main/res/drawable/menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/mute.xml b/app/src/main/res/drawable/mute.xml new file mode 100644 index 0000000..e45c74f --- /dev/null +++ b/app/src/main/res/drawable/mute.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/pinned.xml b/app/src/main/res/drawable/pinned.xml new file mode 100644 index 0000000..e270350 --- /dev/null +++ b/app/src/main/res/drawable/pinned.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/read.xml b/app/src/main/res/drawable/read.xml new file mode 100644 index 0000000..4eb1fdc --- /dev/null +++ b/app/src/main/res/drawable/read.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/reorder.xml b/app/src/main/res/drawable/reorder.xml new file mode 100644 index 0000000..5e3231e --- /dev/null +++ b/app/src/main/res/drawable/reorder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/scame.xml b/app/src/main/res/drawable/scame.xml new file mode 100644 index 0000000..f6d9704 --- /dev/null +++ b/app/src/main/res/drawable/scame.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/verified_ic.xml b/app/src/main/res/drawable/verified_ic.xml new file mode 100644 index 0000000..dac159e --- /dev/null +++ b/app/src/main/res/drawable/verified_ic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2d026df..2f87d23 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,13 +1,52 @@ + + + + + + + + + + + android:layout_height="20dp" + android:background="@color/light_grey" + app:layout_constraintBottom_toBottomOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/chat_item.xml b/app/src/main/res/layout/chat_item.xml new file mode 100644 index 0000000..17f88ad --- /dev/null +++ b/app/src/main/res/layout/chat_item.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..05ca42f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ - + #517DA2 + #FFBB86FC #FF6200EE #FF3700B3 @@ -7,4 +8,9 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + #F0F0F0 + #868686 + #51AEE7 + #66A9E0 + #48A938 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2187cf1..0cda76c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,7 +2,7 @@ + \ No newline at end of file diff --git a/build.gradle b/build.gradle deleted file mode 100644 index bd20018..0000000 --- a/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.application' version '8.7.2' apply false - id 'com.android.library' version '8.7.2' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false -} - -task clean(type: Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..25e3a4c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ + + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false +} +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9c9c566 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,28 @@ +[versions] +agp = "8.13.0" +kotlin = "2.2.20" +coreKtx = "1.17.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.14.0-alpha05" +activity = "1.11.0" +constraintlayout = "2.2.1" +recyclerview = "1.4.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +#implementation 'com.google.android.material:material:1.14.0-alpha05' +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8c7b120..a4f35f8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Nov 05 09:40:34 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle.kts similarity index 94% rename from settings.gradle rename to settings.gradle.kts index a7dbdf4..a3dfaa3 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -1,8 +1,8 @@ pluginManagement { repositories { - gradlePluginPortal() google() mavenCentral() + gradlePluginPortal() } } dependencyResolutionManagement { @@ -12,5 +12,6 @@ dependencyResolutionManagement { mavenCentral() } } + rootProject.name = "RecyclerView" -include ':app' +include(":app")