구글 콘솔에서 정보 설정

https://console.cloud.google.com/welcome?hl=ko&inv=1&invt=Abo6BQ&project=deep-contact-450106-u3

먼저 구글 콘솔에서 앱에 관한 설정을 진행합니다

 

새 프로젝트를 설정한 후 프로젝트 이름을 적습니다

 

그 후 API 및 서비스로 들어가서 OAuth 동의 화면을 만듭니다

 

그 다음 사용자 인증 정보에 들어갑니다

사용자 인증 정보를 만듭니다

 

사용자 인증 정보에서 OAuth 클라이언트 ID를 선택합니다

 

총 3개를 만들겁니다

debug용 android, release용 android, 웹 어플리케이션입니다

먼저 debug용 android 입니다

어플리케이션 유형에서 android를 선택한 후 필요한 정보를 입력합니다

이름은 알아보기 쉽게 android debug로 하겠습니다

 

다음은 release용 android입니다

위와 마찬가지로 필요한 정보를 입력하는데 여기서는 기존에 안드로이드 gradle에서 signingreport를 통해 얻은 SHA-1키를 사용할 수 없습니다

방법은 링크를 통해 확인하시면 되겠습니다

https://blog.naver.com/jungeun85623/220607717078

여기서 apk 파일까지 만든 후

build.gradle 파일에 다음과 같이 적고 synk를 해줍니다

android {
	signingConfigs {
        create("release") {
            storeFile = file("C:/DATA/LoginTest/keystore.jks") // 예시
            storePassword = "aaaa1111" // 예시
            keyAlias = "key1" // 예시
            keyPassword = "aaaa1111" // 예시
        }
    }
    
    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release") // 추가
        }
    }
    ...
}

 

그 후 다시 signingreport를 하시면 release용 SHA-1키도 얻을 수 있습니다

이렇게 release용 SHA-1키를 가지고 debug용 android를 만들었던 것처럼 동일하게 진행합니다

이름은 알아보기 쉽게 android release로 하겠습니다

 

마지막으로 웹어플리케이션입니다

어플리케이션 유형을 웹어플리케이션으로 선택한 후 추가합니다

그럼 다음과 같습니다

 

파이어베이스에서 정보 설정

다음은 파이어베이스에서 로그인 정보를 설정합니다

https://console.firebase.google.com/u/0/?hl=ko

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

 

새 프로젝트를 생성합니다

어플 이름을 입력 후 프로젝트 설정에 들어갑니다

 

그 후 앱을 추가해줍니다

 

그 후 필요한 작업을 해줍니다

다음으로 debug용 SHA-1키와 release용 SHA-1키를 추가합니다

 

그 후 google-service.json을 안드로이드 프로젝트 app/에 넣어줍니다

build.gradle 파일 설정

// module
dependencies {
		...
    // google login
    implementation(libs.play.services.auth)
    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth.ktx)
    implementation(libs.firebase.analytics)
}
// project
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    id("com.google.gms.google-services") version "4.4.2" apply false
}

 

코드 구현

<resources>
    <string name="app_name">LoginTest</string>
    <string name="google_client_id">...</string>
</resources>

여기서 중요한 점은 google_client_id를 아까 구글 콘솔에서 설정한 웹어플리케이션의 id를 가져와야한다는 점입니다

절대 android키로 가져오면 안됩니다

 

 

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    private lateinit var mGoogleSigninClient: GoogleSignInClient // 선언 추가

    private val googleLoginResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
        val resultCode = result.resultCode
        val data = result.data

        try {
            val completedTask = GoogleSignIn.getSignedInAccountFromIntent(data)
            val account = completedTask.getResult(ApiException::class.java)
            onLoginCompleted("${account?.id}", "${account?.idToken}")
        } catch (e: ApiException) {
            onError(Error(e))
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // google login
        binding.signInButton.setOnClickListener {
            signInWithGoogle()
        }
    }

    private fun signInWithGoogle() {
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.google_client_id)) // 요청할 때마다 받아오기
            .requestEmail()
            .build()

        mGoogleSigninClient = GoogleSignIn.getClient(this, gso)
        googleLoginResult.launch(mGoogleSigninClient.signInIntent)
    }

    private fun onLoginCompleted(userId: String?, accessToken: String?){
        Toast.makeText(this, "구글 로그인 성공", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "userId: $userId / accessToken: $accessToken")
    }
    private fun onError(error : Error?){
        Toast.makeText(this, "구글 로그인 실패", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "구글 로그인 실패 onError / error: ${error} / error.msg: ${error?.message}")
    }
}

결과

# https://developers.naver.com/docs/login/android/android.md 접속

 

# 오픈 API 이용 신청

어플리케이션과 로그인 시 사용할 정보를 선택한다.

 

어플을 다운로드 할 URL과 안드로이드 앱 패키지 이름을 작성한다.

URL은 마땅히 없으면 네이버 URL을 적어도 된다.

 

등록하기를 누르면 Client ID와 Client Secret이 나온다.

이 정보를 프로젝트의 res/string.xml에 적어두도록 하자.

<resources>
    <string name="app_name">LoginTest</string>
    <string name="naver_client_id">client id 작성</string>
    <string name="naver_client_secret">client secret 작성</string>
    <string name="naver_client_name">client name 작성</string>
</resources>

 

# 개발 환경 설정

네이버 로그인 API를 사용하기 위해 다음과 같이 gradle파일을 설정한다.

dependencies {
	...
    
    // naver login
    implementation(libs.oauth.jdk8) // jdk 8
    
    // naver login dependency
    implementation (libs.kotlin.stdlib)
    implementation (libs.kotlinx.coroutines.android)
    implementation (libs.androidx.appcompat.v131)
    implementation (libs.androidx.legacy.support.core.utils)
    implementation (libs.androidx.browser)
    implementation (libs.androidx.legacy.support.v4)
    implementation (libs.androidx.constraintlayout.v113)
    implementation (libs.androidx.security.crypto)
    implementation (libs.androidx.core.ktx.v130)
    implementation (libs.retrofit)
    implementation (libs.converter.gson)
    implementation (libs.moshi.kotlin)
    implementation (libs.logging.interceptor)
    implementation (libs.lottie)
}

 

 

activity_main.xml에 네이버 로그인 버튼을 만든다.

이때 네이버 로그인 버튼은 네이버에서 정한 디자인을 따라야 한다.

com.navercorp.nid.oauth.view.NidOAuthLoginButton

이 버튼을 사용하거나, 혹은 다른 디자인을 원한다면 아래의 디자인 가이드를 참고하면 되겠다.

https://developers.naver.com/docs/login/bi/bi.md

 

로그인 버튼 사용 가이드 - LOGIN

네이버 로그인은 애플리케이션에 사용할 수 있는 네이버 로그인 버튼 기본 이미지를 제공합니다. 애플리케이션의 상황에 맞게 버튼 이미지의 디자인을 변경할 수 있지만 네이버 고유의 아이덴

developers.naver.com

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.navercorp.nid.oauth.view.NidOAuthLoginButton
        android:id="@+id/buttonOAuthLoginImg"
        android:layout_width="wrap_content"
        android:layout_height="50dp"
        android:layout_marginTop="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

이후 다음과 같은 코드를 작성하면 네이버 로그인 구현을 완성할 수 있다.

package com.example.logintest

import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.example.logintest.databinding.ActivityMainBinding
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.navercorp.nid.NaverIdLoginSDK
import com.navercorp.nid.oauth.OAuthLoginCallback

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // naver login
        NaverIdLoginSDK.initialize(this, getString(R.string.naver_client_id), getString(R.string.naver_client_secret), getString(R.string.naver_client_name))

        binding.buttonOAuthLoginImg.setOAuthLogin(object : OAuthLoginCallback {
            override fun onSuccess() {
                Log.d("test", "AccessToken : " + NaverIdLoginSDK.getAccessToken())
                Log.d("test", "client id : " + getString(R.string.naver_client_id))
                Log.d("test", "ReFreshToken : " + NaverIdLoginSDK.getRefreshToken())
                Log.d("test", "Expires : " + NaverIdLoginSDK.getExpiresAt().toString())
                Log.d("test", "TokenType : " + NaverIdLoginSDK.getTokenType())
                Log.d("test", "State : " + NaverIdLoginSDK.getState().toString())
            }

            override fun onFailure(httpStatus: Int, message: String) {
                val errorCode = NaverIdLoginSDK.getLastErrorCode().code
                val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
                Log.e("test", "$errorCode $errorDescription")
            }

            override fun onError(errorCode: Int, message: String) {
                onFailure(errorCode, message)
            }
        })

    }
}

# https://developers.kakao.com/ 접속

# 로그인 후 상단 ‘내 어플리케이션’에서 어플리케이션 추가

 

# 클릭해서 진입 후 플랫폼을 설정한다

 

안드로이드 플랫폼 등록 클릭

 

키 해시 등록하기

build.gradlekts(Module) 파일에 모듈을 추가

maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } 작성 후 싱크해준다

 

필요한 모듈을 등록해준다 

implementation("com.kakao.sdk:v2-all:2.20.0") // 전체 모듈 설치, 2.11.0 버전부터 지원

 

GlobalApplication 파일을 만들어준다

class GlobalApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 다른 초기화 코드들

        // Kakao SDK 초기화
        KakaoSdk.init(this, "{NATIVE_APP_KEY}")
    }
}

 

앱 키는 해당 부분에서 확인할 수 있다

 

manifest 파일에 다음을 추가한다

android:name=".GlobalApplication"

 

메인화면에 다음을 추가하고 실행하면 해시키가 나온다

var keyHash = Utility.getKeyHash(this)
Log.i("kjwTest", "keyHash: $keyHash")

 

# 카카오 로그인 활성화

 

코드 작성

binding.btnKakaoLogin.setOnClickListener {
            kakaoLogin_Step01()
        }
fun kakaoLogin_Step01() {
        if (UserApiClient.instance.isKakaoTalkLoginAvailable(this)) {
            // 카카오톡 로그인
            UserApiClient.instance.loginWithKakaoTalk(this) { token, error ->
                // 로그인 실패 부분
                if (error != null) {
                    Log.e("kj308", "로그인 실패", error)
                    // 사용자가 취소
                    if (error is ClientError && error.reason == ClientErrorCause.Cancelled ) {
                        return@loginWithKakaoTalk
                    }
                    // 다른 오류
                    else {
                        UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
                    }
                }
                // 로그인 성공 부분
                else if (token != null) {
                    Log.e("kj308", "로그인 성공 ${token.accessToken}")
                    UserApiClient.instance.me {
                        user, error ->
                        if (error != null) {
                            Log.e("kj308", "사용자 정보 요청 실패 $error")
                        } else if (user != null) {
                            Log.e("kj308", "사용자 정보 요청 성공 : $user")
                        }
                    }
                }
            }
        } else {
            UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
        }
    }

    private val mCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
        if (error != null) {
            Log.e("kj308", "로그인 실패 $error")
        } else if (token != null) {
            Log.e("kj308", "로그인 성공 ${token.accessToken}")
        }
    }

 

인터넷 설정

<uses-permission android:name="android.permission.INTERNET" />

 

→ 결과

# 사진 넣기

1. 어댑터 클래스에서 뷰 홀더를 bind 해줄 때 어댑터 position을 이용한다

override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        when (position) {
            0 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (position > 5) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

- 더 효율적으로 작성하려면?

val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

2. update activity에 사진 넣기

- 위와 같이 이미지 배열 사용

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(updateBinding.root)

        val dto = intent.getSerializableExtra("dto") as MovieDto
		...

        if (dto.id in 1..imageResources.size) {
            updateBinding.ivUpdatePhoto.setImageResource(imageResources[dto.id - 1])
        } else {
            updateBinding.ivUpdatePhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

# 검색 기능 만들기

1. 검색 레이아웃과 메뉴칸을 추가한다

- 레이아웃

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView5"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="영화 제목 검색하기"
        android:textSize="25sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/etSearch"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:ems="10"
        android:hint="검색어 입력"
        android:inputType="text"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.497"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView5" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvSearch"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btnSearchCancel"
        app:layout_constraintVertical_bias="0.0" />

    <Button
        android:id="@+id/btnSearchCancel"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="뒤로가기"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etSearch" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

- 메뉴칸 추가

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menuItem01"
        android:title="영화 추가" />
    <item
        android:id="@+id/menuItem02"
        android:title="개발자 정보" />
    <item
        android:id="@+id/menuItem03"
        android:title="영화 정보 검색" />
    <item
        android:id="@+id/menuItem04"
        android:title="앱 종료"/>
</menu>

 

 

2. 검색 기능을 위한 어댑터를 새로 생성한다

- 기존 어댑터와 유사하지만 다른 점은 onBindHolder에서 item을 바인딩할 때 전체 리스트가 아닌 검색한 리스트를 바인딩한다.

override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

 

- getFilter 함수를 구현한다

override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }

 

- 전체 코드는 다음과 같다

더보기
package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ListItemBinding

class SearchAdapter(val movies: ArrayList<MovieDto>)
    : RecyclerView.Adapter<SearchAdapter.SearchViewHolder>(), Filterable {
    val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
    private var filteredList: List<MovieDto> = movies
    private val unFilteredList: List<MovieDto> = movies

    override fun getItemCount(): Int = filteredList.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
        val itemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return SearchViewHolder(itemBinding)
    }

     override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

    inner class SearchViewHolder(val itemBinding: ListItemBinding)
        : RecyclerView.ViewHolder(itemBinding.root) {

    }

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }
}

 

3. SearchActivity 구현하기

- SearchAdapter를 활용하여 액티비티를 완성한다.

package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import ddwu.com.mobile.finalreport.data.MovieDao
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ActivitySearchBinding

class SearchActivity : AppCompatActivity() {
    val searchBinding by lazy {
        ActivitySearchBinding.inflate(layoutInflater)
    }
    lateinit var movies: ArrayList<MovieDto> // 데이터
    val movieDao by lazy {
        MovieDao(this)
    }

    @SuppressLint("Range")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(searchBinding.root)

        // Fetch all movies from the database
        movies = movieDao.getAllMovies()

        // Setup RecyclerView
        val adapter = SearchAdapter(movies)
        searchBinding.rvSearch.layoutManager = LinearLayoutManager(this)
        searchBinding.rvSearch.adapter = adapter

        // Add TextWatcher to filter movies based on user input
        searchBinding.etSearch.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                adapter.filter.filter(s)
            }

            override fun afterTextChanged(s: Editable?) {}
        })

        // Cancel button click listener
        searchBinding.btnSearchCancel.setOnClickListener {
            finish()
        }
    }
}

 

4. 메인에서 메뉴 클릭 시 액티비티 전환 구현

R.id.menuItem03 -> {
                val intent = Intent(this@MainActivity, SearchActivity::class.java)
                startActivity(intent)
            }

 

5. 오류

- 검색 시 이미지가 filteredList의 position에 걸리는 것이 아닌 dto의 id에 영향을 받아야 제대로 이미지가 나온다

when (filteredList[position].id) {
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            6 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (filteredList[position].id > 6) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# 구현하고자 하는 것

- recycle view 클릭 시 수정 창이 나온다

- 수정한 결과가 반영된다

 

# 어댑터 클릭 인터페이스 작성

// 클릭 인터페이스
    interface OnItemClickListener {
        fun onItemClick(view: View, position: Int)
    }

    var listener: OnItemClickListener? = null

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.listener = listener
    }

 

 

# Update Activity 작성

- id를 사용

class UpdateActivity : AppCompatActivity() {
    val updateBinding by lazy {
        ActivityUpdateBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(updateBinding.root)

        val dto = intent.getSerializableExtra("dto") as MovieDto

        updateBinding.etUpdateMovie.setText(dto.movie)
        updateBinding.etUpdateRate.setText(dto.rate)
        updateBinding.etUpdateDirector.setText(dto.director)

        updateBinding.btnUpdate.setOnClickListener {
            dto.movie = updateBinding.etUpdateMovie.text.toString()
            dto.rate = updateBinding.etUpdateRate.text.toString()
            dto.director = updateBinding.etUpdateDirector.text.toString()

            if (updateMovie(dto) > 0) {
                setResult(RESULT_OK)
            }
            else {
                setResult(RESULT_CANCELED)
            }

            finish()
        }

        updateBinding.btnUpdateCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }
    }

    fun updateMovie(dto: MovieDto): Int {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val updateValue = ContentValues()
        updateValue.put(MovieDBHelper.COL_MOVIE, dto.movie)
        updateValue.put(MovieDBHelper.COL_RATE, dto.rate)
        updateValue.put(MovieDBHelper.COL_DIRECTOR, dto.director)

        val whereClause = "${BaseColumns._ID}=?"
        val whereArgs = arrayOf(dto.id.toString())

        Log.d("UpdateActivity", "Updating movie with ID: ${dto.id}, values: $updateValue")

        val resultCount = db.update(MovieDBHelper.TABLE_NAME,
            updateValue, whereClause, whereArgs)

        Log.d("UpdateActivity", "Rows updated: $resultCount")

        helper.close()
        return resultCount
    }
}

 

# Main

- 어댑터 연결

adapter.setOnItemClickListener(object: MovieAdapter.OnItemClickListener {
            override fun onItemClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, UpdateActivity::class.java)
                intent.putExtra("dto", movies.get(position))   // 클릭한 RecyclerView 항목 위치의 dto 전달
                startActivityForResult(intent, REQ_UPDATE)      // 수정결과를 받아오도록 Activity 호출
            }
        })

 

- intent 결과 반환

REQ_UPDATE -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

 

 

# 구현하고자 하는 것

- 메뉴 클릭 시 나오는 정보 추가를 클릭하면 데이터를 추가하는 창 띄우기

- 데이터 추가 창에서 정보 입력 후 확인 누르면 데이터 반영

 

# 정보 추가 레이아웃 만들기

- textView와 plainText 사용

- 버튼은 linearLayout 사용하여 병렬 정리

<LinearLayout
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etAddDirector">

        <Button
            android:id="@+id/btnAdd"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="추가" />

        <Button
            android:id="@+id/btnCancel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="취소" />
    </LinearLayout>

 

다른 정보 추가도 넣음

 

# 메뉴 아이템 클릭했을 때 화면이 바뀌게 구성

- 아이템 id 선택지 구현

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.menuItem01 -> {
               
            }
            R.id.menuItem02 -> {

            }
            R.id.menuItem03 -> {

            }
        }
        return true
    }

 

- 정보 추가창 id=menuItem01, intent를 사용하여 activity 바꾼다

R.id.menuItem01 -> {
       val intent = Intent(this@MainActivity, AddActivity::class.java)
       startActivityForResult(intent, REQ_ADD)
}

 

- AddActivity를 만든다

class AddActivity : AppCompatActivity()  {
    val addBinding by lazy {
        ActivityAddBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(addBinding.root)
    }
}

 

- AndroidManifest.xml 에 AddActivity를 추가한다

<activity android:name=".AddActivity" />

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# SQLDatabase 설정

class MovieDBHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, 1) {
    val TAG = "MovieDBHelper"
    
    companion object {
        const val DB_NAME = "movie_db"
        const val TABLE_NAME = "movie_table"
        const val COL_MOVIE = "movie"
        const val COL_RATE = "rate"
        const val COL_DIRECTOR = "director"
        const val COL_DATE = "date"
    }

    override fun onCreate(db: SQLiteDatabase?) {
        val CREATE_TABLE =
            "CREATE TABLE $TABLE_NAME ( ${BaseColumns._ID} INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "$COL_MOVIE TEXT, $COL_RATE TEXT, $COL_DIRECTOR TEXT, $COL_DATE TEXT )"
        Log.d(TAG, CREATE_TABLE)    // SQL 문장이 이상 없는지 Log에서 확인 필요
        db?.execSQL(CREATE_TABLE)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        val DROP_TABLE ="DROP TABLE IF EXISTS $TABLE_NAME"
        db?.execSQL(DROP_TABLE)
        onCreate(db)
    }
}

 

- 샘플 데이터 추가

// 샘플 데이터 넣음
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '은혼 더 파이널', '4.5', '미야와키 치즈루', '2021-07-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '어벤저스 엔드게임', '4.3', '앤서니 루소', '2019-04-24')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '엘리멘탈', '4.1', '피터 손', '2023-06-14')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '파묘', '4.8', '장재현', '2024-02-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '범죄도시3', '3.8', '이상용', '2023-05-31')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '인사이드 아웃2', '4.1', '켈시 만', '2024-06-12')")

 

 

# AddActivity 설정

- 확인 버튼 / 취소 버튼 구현

addBinding.btnAdd.setOnClickListener {
            val addETMovie = addBinding.etAddMovie.text.toString()
            val addETRate = addBinding.etAddRate.text.toString()
            val addETDirector = addBinding.etAddDirector.text.toString()
            val addETDate = addBinding.etAddRate.text.toString()

            if (addMovie(MovieDto( 0, addETMovie, addETRate, addETDirector, addETDate)) > 0) {
                setResult(RESULT_OK)
            } else {
                setResult(RESULT_CANCELED)
            }
            finish()
        }

        addBinding.btnCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }

 

- addMovie 함수 구현

fun addMovie(newDto: MovieDto) : Long {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val newValue = ContentValues()
        newValue.put(MovieDBHelper.COL_MOVIE, newDto.movie)
        newValue.put(MovieDBHelper.COL_RATE, newDto.rate)
        newValue.put(MovieDBHelper.COL_DIRECTOR, newDto.director)
        newValue.put(MovieDBHelper.COL_DATE, newDto.date)

        val newCount = db.insert(MovieDBHelper.TABLE_NAME, null, newValue)

        helper.close()

        return newCount
    }

 

# add 데이터를 main에서 받기

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            REQ_ADD -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }
        }
    }

 

 

# 몰랐던 부분

- 데이터베이스에 샘플 데이터를 나중에 넣었을 시 기존 앱 삭제 후 처음부터 run해줘야 반영이 된다

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# 구현하고자 하는 것

- recycler view 롱 클릭 시 해당 뷰 삭제

 

# 코드 구현

- MovieAdapter에서 이벤트 리스너 구현

inner class MovieViewHolder(val itemBinding: ListItemBinding)
        : RecyclerView.ViewHolder(itemBinding.root) {
        init {
            itemBinding.root.setOnLongClickListener{
                longClickListener.onItemLongClick(it, adapterPosition)
            }
        }
    }

    interface onItemLongClickListener {
        fun onItemLongClick(view: View, position: Int) : Boolean
    }

    lateinit var longClickListener: onItemLongClickListener

    fun setOnItemLongClickListener (listener: onItemLongClickListener) {
        this.longClickListener = listener
    }

 

- Main Activity에서 기능을 구현한 listener 객체를 adapter에 전달

val listener = object: MovieAdapter.onItemLongClickListener {
            override fun onItemLongClick(view: View, position: Int): Boolean {
                val builder = AlertDialog.Builder(this@MainActivity)
                builder.setTitle("영화 정보 삭제")
                    .setMessage("정말 삭제하시겠습니까?")
                    .setPositiveButton(
                        "확인"
                    ) { dialog, which ->
                        movies.removeAt(position)
                        adapter.notifyDataSetChanged() // 화면 갱신
                    }
                    .setNegativeButton(
                        "취소"
                    ) { dialog, which ->
                        adapter.notifyDataSetChanged() // 화면 갱신
                    }
                builder.show()
                return true
            }
}
adapter.setOnItemLongClickListener(listener)

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# DTO 의 작성 및 샘플 데이터 추가

1. DTO(Data Transfer Object) 구현

- Item 항목 하나는 이미지와 두 개의 텍스트의 집합(영화 사진, 영화 제목, 영화 평점)

- 관련 있는 여러 데이터 값을 하나로 묶어 다른 계층에 전송 하는 용도로 사용하는 Design Pattern

data class MovieDto(val photo: Int, val movie: String, val rate: Int) {
    override fun toString() = "$movie ($rate)"
}

 

2. 샘플 데이터 추가

class MovieDao {
    val movies = ArrayList<MovieDto>()

    init {
        movies.add (MovieDto(R.drawable.movie01, "은혼 더 파이널", 4.5))
        movies.add (MovieDto(R.drawable.movie02, "어벤저스 엔드게임", 4.3))
        movies.add (MovieDto(R.drawable.movie03, "엘리멘탈", 4.1))
        movies.add (MovieDto(R.drawable.movie04, "파묘", 4.8))
        movies.add (MovieDto(R.drawable.movie05, "범죄도시3", 3.8))
        movies.add (MovieDto(R.drawable.movie06, "인사이드 아웃2", 4.1))
    }
}

 

3. Adapter 구현

class MovieAdapter(val movies: ArrayList<MovieDto>)
    : RecyclerView.Adapter<MovieAdapter.MovieViewHolder>() {
    override fun getItemCount(): Int = movies.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieViewHolder {
        val itemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return MovieViewHolder(itemBinding)
    }

    override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        holder.itemBinding.ivPhoto.setImageResource(movies[position].photo)
        holder.itemBinding.tvMovie.text = movies[position].movie
        holder.itemBinding.tvRate.text = movies[position].rate.toString()
    }

    inner class MovieViewHolder(val itemBinding: ListItemBinding): RecyclerView.ViewHolder(itemBinding.root) {
        
    }
}

 

4. MainActivity에서 RecylcerView와 Adapter의 연결

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        val movies = MovieDao().movies // 데이터
        val adapter = MovieAdapter(movies) // 어댑터
        
        val layoutManager = LinearLayoutManager(this) // 레이아웃 매니저 생성 및 설정
        layoutManager.orientation = LinearLayoutManager.VERTICAL
        
        binding.recyclerView.layoutManager = layoutManager
        binding.recyclerView.adapter = adapter
}

감독 텍스트뷰 추가함

 

# 필요한 레이아웃 설정

1. 메인 화면 설정

- 텍스트 뷰

<TextView
        android:id="@+id/textView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:text="영화 목록"
        android:textSize="25sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

- 리사이클러 뷰

<androidx.recyclerview.widget.RecyclerView
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.498"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

 

 

2. 리스트 화면 설정

<ImageView
        android:id="@+id/ivPhoto"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/ic_launcher_foreground" />

    <TextView
        android:id="@+id/tvFood"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="32dp"
        android:layout_marginTop="16dp"
        android:text="TextView"
        android:textSize="24sp"
        app:layout_constraintStart_toEndOf="@+id/ivPhoto"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tvCount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="32dp"
        android:layout_marginBottom="16dp"
        android:text="TextView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/ivPhoto" />

 

 

# 메뉴 설정

<style name="Base.Theme.FinalReport_01_20220790" parent="Theme.Material3.DayNight">
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menuItem01"
        android:title="영화 추가" />
    <item
        android:id="@+id/menuItem02"
        android:title="개발자 정보" />
    <item
        android:id="@+id/menuItem03"
        android:title="앱 종료"/>
</menu>

 

# 메뉴바 이름 바꾸기

<resources>
    <string name="app_name">영화 정보 앱</string>
</resources>

 

기본 대화상자 생성

val builder: AlerDialog.Builder(this).apply {
    setTitle(“”)
    setMessage(“”)
    setPositiveButton(“확인”) { }
    setNegativButton(“취소”, null)
} 
builder.show()

 

이벤트 처리

- 변수

val click = DialogInterface.onClickListener {
    dialogInterface: DialogInterface?, idx: Int ->
    ...
}
setPositiveButton(“”, click)

 

- 람다함수

setPositiveButton(“확인”) {
    dialogInterface: DialogInterface?, idx: Int ->
    ...
}

 

목록이 있는 대화상자

- 기본 목록

setItems(R.array.foods) {
    dialogInterface: DialogInterface?, idx: Int ->
    val foods = resources.getStringArray(R.array.foods)
    ...
}

- 라디오버튼 목록

val selectedIdx = 0
setSingleChoiceItems(R.array.foods, selectedIdx) {
    dialogInterface: DialogInterface?, idx: Int ->
    selectedIdx = idx
}

setPositivebutton에서 구현

- 다중선택 목록

val selectedItems = booleanArrayOf(false, false, false)
setItems(R.array.foods, selectedItems) {
    dialogInterface: DialogInterface?, idx: Int, isChecked: Boolean ->
    selectedItems[idx] = ischecked
}

setPositiveButton에서 구현

 

커스텀 대화상자

대화상자가 layoutXml형식

val dialogLayout = DialogLayoutBinding.inflate(layoutInflater)

builder 안 setView(dialogLayout.root)

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

+ Recent posts