2026/03/21

오늘의 이야기

이전 글에서 정리할 것처럼 java에서 kotlin으로 이전을 했습니다.  그러고 나서 보기 시작했는 데,  DefaultSharedPrefernces의 사용할 수 없는 환경으로 변경이 된 것을 알게 되었습니다. 


 


이전

prefs = PreferenceManager.getDefaultSharedPreferences(this)

 


이후

prefs = getSharedPreferences(packageName + "_preferences", MODE_PRIVATE)

기본 preferences 의 이름은 패키지 이름 뒤에 접미사가 달라붙어 있었습니다.   


 


이렇게 해서 또 하나 해소가 되었습니다.


 


환경설정



 





오늘의 이야기

이 글은 기존에 사용하던 java 프로젝트를 kotlin으로 전환해 보기 위해서 작성하고 있습니다. 이 글은 아래 링크의 개발자 페이지의 내용을 참고하고 있습니다.


 


https://developer.android.com/kotlin/add-kotlin?hl=ko#kts 



 


기존 앱에 Kotlin 추가  |  Android Developers


기존 앱에 Kotlin 추가 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android 스튜디오에서는 Kotlin을 완벽하게 지원하므로 기존 프로젝트에 Kotlin 파일을 추가


developer.android.com




 


먼저 gradle 파일부터 수정해 보겠습니다. 


 


buildscript {

ext { // 추가한 부분
compose_ui_version = '1.5.0-alpha04'
raamcosta_version = '1.9.42-beta'
kotlin_version = '1.8.22'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.2'
classpath 'com.google.gms:google-services:4.3.15'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.7'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // 추가한 부분
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

다음 모듈 수준의 gradle 파일 수정입니다.


import java.text.SimpleDateFormat

plugins {
id 'com.android.application'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id 'kotlin-android' // 추가한 부분
}

android {
signingConfigs {
upload {
storeFile file(var)
storePassword var
keyAlias = var
keyPassword var
}
}
compileSdkVersion 34
namespace='com.n.......y2kakao'

defaultConfig {
applicationId "com.n......akao"
minSdkVersion 26
targetSdkVersion 34
versionCode 41
versionName '1.4.1'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

renderscriptTargetApi 18
renderscriptSupportModeEnabled true

}

sourceSets { // 추가한 부분
getByName("main") {
java.srcDir("src/main/kotlin")
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

buildTypes {
debug {
minifyEnabled false // 2022.06.23 true 가 되었더니, 압축이 되서 null 오류가 난다.
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled false // 2022.06.23 true 가 되었더니, 압축이 되서 null 오류가 난다.
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
viewBinding{
enabled = true
}

def archiveBuildType = ["release"]
namespace 'com.nari.notify2kakao'
applicationVariants.all { variant ->
variant.outputs.each { output ->
if (variant.buildType.name in archiveBuildType) {
def df = new SimpleDateFormat("yyyyMMdd")
df.setTimeZone(TimeZone.getDefault())
if (variant.versionName != null) {
String name = "Notify2Kakao_${df.format(new Date())}_${defaultConfig.versionCode}_${variant.versionName}.apk"
output.outputFileName = name
}
}
}
}
}

dependencies {

constraints { // 추가한 부분
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") {
because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
}

implementation('androidx.work:work-runtime:2.7.1') {
because '''androidx.work:work-runtime:2.1.0 pulled from play-services-ads
has a bug using PendingIntent without FLAG_IMMUTABLE or
FLAG_MUTABLE and will fail in apps targeting S+.'''
}
}

implementation("androidx.core:core-ktx:1.10.1") // 추가한 부분
implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") // 추가한 부분

implementation "com.kakao.sdk:v2-all:2.15.0" // 전체

implementation "com.google.android.play:core:1.10.3"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.gms:google-services:4.3.15'
implementation platform('com.google.firebase:firebase-bom:30.1.0')
implementation 'com.google.firebase:firebase-crashlytics'
implementation 'com.google.firebase:firebase-analytics'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
implementation 'com.google.android.gms:play-services-ads:22.2.0'
implementation 'com.google.android.gms:play-services-maps:18.1.0'
implementation 'androidx.mediarouter:mediarouter:1.4.0'
implementation 'com.google.guava:guava:29.0-android'

// 데이터 주고 받기
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:converter-simplexml:2.9.0'

}

 


실행하는 방법은 메뉴에서 Code 메뉴 아래에 보면 Convert Java File To Kotlin File 메뉴가 있는 데, 저는 아래 그림처럼 변환할 대상을 찾고 오른쪽 마우스 클릭해서 해 보도록 하겠습니다.


변환해 보기



 


이제 java 코드를 kotlin 으로 하나씩 변환해 보겠습니다.


 


변환하면서 쉽게 만나는 오류 표시



이름 그림과 같이 kotlin으로 변환된 뒤에 변수 처리 등에 오류 표시가 나오는 경우를 종종 만나게 됩니다.  사유는 해당 변수를 변환하면서 null 허용 상태를 만들어 가면서 변환을 하기 때문입니다. 선언된 변수를 찾아보겠습니다. 


 




이렇게 null 로 초기화를 해야만 하는 가 봅니다.  kotlin 에는 초기화를 나중에 하는 유용한 방법이 있는 데도 말입니다.  그래서 다음과 같이 변경을 해 주면 해결이 됩니다. 




이제 소스 코드에 표시 되었던 오류는 사라집니다.  다만, 실제로 source 코드에서는 해당 변수를 사용하기 전에 꼭 초기화를 해야 실행 시 오류가 발생하지 않습니다. 


 


java.lang.NullPointerException: Parameter specified as non-null is null: 

또한 java.lang.NullPointerException: Parameter specified as non-null is null 이런 오류를 만나게 되었습니다. 사유는 Recycle View을 이용해서 List에 들어 있는 항목들을 보여주는 화면들이 있는 데, View Adapter 등을 사용했었다면 만나게 될 것 같습니다. 


 


                 Process: com.nari.notify2kakao, PID: 17268
java.lang.NullPointerException: Parameter specified as non-null is null: method com.nari.notify2kakao.util.ViewStrValueAdapter.getView, parameter convertView
at com.nari.notify2kakao.util.ViewStrValueAdapter.getView(Unknown Source:2)
at android.widget.AbsListView.obtainView(AbsListView.java:2466)
at android.widget.ListView.makeAndAddView(ListView.java:2065)
at android.widget.ListView.fillDown(ListView.java:791)
at android.widget.ListView.fillFromTop(ListView.java:853)
at android.widget.ListView.layoutChildren(ListView.java:1836)
at android.widget.AbsListView.onLayout(AbsListView.java:2263)
at android.view.View.layout(View.java:24421)

 


아래 Adapter 예시와 같이 getView 의 리턴값이 View? 이 될 수 있도록 조치를 해 주시면 해소가 됩니다.


class ViewWithDrawMonthlyAdapter(oData: ArrayList<ViewWithDrawMonthly>) : BaseAdapter() {
private var viewWithDrawMonthlyls = ArrayList<ViewWithDrawMonthly>()

init {
viewWithDrawMonthlyls = oData
}

override fun getCount(): Int {
return viewWithDrawMonthlyls.size
}

override fun getItem(position: Int): Any {
return viewWithDrawMonthlyls[position]
}

override fun getItemId(position: Int): Long {
return position.toLong()
}

override fun getView(position: Int, containView: View?, parent: ViewGroup): View? {
var containView = containView
val context = parent.context
if (containView == null) {
val inflater =
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
containView = inflater.inflate(R.layout.viewwithdrawmonthly_item, parent, false)
}
val chkTy = containView?.findViewById<CheckBox>(R.id.chkTy)
val monthlyDay = containView?.findViewById<TextView>(R.id.editMonthlyDay)
val remark = containView?.findViewById<TextView>(R.id.editRemark)
val outamt = containView?.findViewById<TextView>(R.id.editOutAmt2)
val textSplit = containView?.findViewById<TextView>(R.id.textSplit)
val textSplit1 = containView?.findViewById<TextView>(R.id.textSplit1)
val viewWithDrawMonthly = viewWithDrawMonthlyls[position]
if (chkTy != null) {
chkTy.isChecked = false
}
if ("Y" == viewWithDrawMonthly.chkTy) {
if (chkTy != null) {
chkTy.isChecked = true
}
}
if (monthlyDay != null) {
monthlyDay.text = viewWithDrawMonthly.getmonthlyDay()
}
if (remark != null) {
remark.text = viewWithDrawMonthly.remark
}
if (outamt != null) {
outamt.text = viewWithDrawMonthly.outAmt
}
return containView
}
}

kotlin에서 non-null 이 null 될 수 없어서 오류가 발생하기 때문이기도 합니다.


 


다음이야기는 뒤에...





오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 "혹시나~" 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 "로또 예상번호 by Gemini" 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기

https://v.daum.net/v/20230720150011670

"신입사원 평균이 50세, 이 무슨?".. 취업 전선도 점점 '고령화'  어쩌다

앞으로 10년 정도 더 지나면 취업시장에서 '젊은' 층을 보기가 쉽지 않을 것이란 관측입니다. 지난해 취업자 평균 나이가 46.8세였는데, 계속 연령대가 올라가 10년 후면 50살, 2050년이면 평균 연

v.daum.net


오늘은 기사 스크랩 하나를 올려 봅니다.  나이는 숫자??? ㅋ~  

생활이 필요한 지금도 그럴까요?  노력해 보아야 할 것 같아요. 50대 중반 이 나이에도 취업능 해야 합니다.  아직도 부양해야 하는 가족이 있어서요.

오늘도 열심히 살아야 할 것 같아요.

야경






오늘의 이야기


#스하리1000명프로젝트

스치니들!
내가 만든 이 앱은, 내 폰에 오는 알림 중에서 중요한 키워드가 있는 경우
등록해둔 친구에게 자동으로 전달해주는 앱이야 📲

예를 들어, 카드 결제 알림을 와이프나 자녀에게 보내주거나
이번 달 지출을 달력처럼 확인할 수도 있어!

앱을 함께 쓰려면 친구도 설치 & 로그인해줘야 해.
그래야 친구 목록에서 서로 선택할 수 있으니까~
서로 써보고 불편한 점 있으면 알려줘 🙏

👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

장마철이 끝나기는 하는 건지?  오늘은 어제와 다른 게 맑은 하늘에 더운 기온을 보냈습니다.


맑인 하늘



어제와는 다르게 하늘이 파란색을 보여 줍니다. 날씨도 무덥고요. 오락가락 날씨에  이제 그만 비는 내리지 않았으면 싶기도 하지만,  내일은 또 어떤 날이 될까는 걱정이 되기도 합니다. 


 


요 몇일 동안 폭우 때문에 힘들어 지신 분들께는 오늘 같은 날씨가 도움이 될까 싶기도 하고요.  후손들에게 돌려주어야 하는 지구가 요새는 엉망이 되어 가고 있다는 생각이 들기도 합니다. 


 


오늘도 무사히 ...

 


어린 시절에 버스나 택시등의 교통수단을 타면 유난히 많이 보이도 기도문 이었던 거 같습니다.  오늘도 잘 마무리가 되시길 기원드려요.


 





오늘의 이야기

활용앱 예시


휴대폰에 있는 사진 (이미지)을 백업해 보아야겠다는 요청을 받았습니다.  하지만, 그때는 방법을 잘 모르겠더군요. 그래서 일단 찾아본다고 했는 데, 그 이후에는 더 이상의 요구를 하지 않았습니다.  그래서 이왕 찾아보았던 정보를 이용해서 앱을 하나 만들어 보기로 했습니다. 
 
https://medium.com/@sendtosaeed2/android-fetch-all-files-from-local-storage-media-store-api-e9b9 14cd71e1

Android Fetch All Files From Local Storage (Media Store API) 🥲

Hi android devs! Hope you are all fine but not fine with the media store api, cause it is good but not good for us. In this article we will…

medium.com

 
처리하는 기술적인 부분에 대해서는 이 글이 참고가 되었습니다. 안드로이드가 External Storage에 대한 access 권한을 제한하기 전에 안드로이드 11 이전에는 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 등으로 카메라 앱으로 촬영한 사진등의 이미지를 접근해 백업을 하거나 하는 처리를 할 수 있었습니다.
 
하지만, 안드로이드가 업데이트를 하는 과정에서 여러 가지 보안절차가 강화되었기 때문에 그걸 활용할 수 없었습니다.
 
https://developer.android.com/training/data-storage/shared/media?hl=ko 

공유 저장소의 미디어 파일에 액세스  |  Android 개발자  |  Android Developers

DataStore는 로컬 데이터를 저장하는 최신 방법을 제공합니다. SharedPreferences 대신 DataStore를 사용해야 합니다. 자세한 내용은 DataStore 가이드를 참고하세요. 공유 저장소의 미디어 파일에 액세스 컬

developer.android.com

그래서 위 참조글에서 보았던 MediaStore API을 활용해 보기로 했습니다. 권한 설정 등의 제한 사항이 발견되었기 때문에 안드로이드 13을 타깃으로 설정했습니다. 
 

gradle
defaultConfig {
applicationId "com.billcorea.......t0710"
minSdk 33
targetSdk 34
versionCode 7
versionName "0.0.7"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}

minSdk 33으로 설정 했습니다. 아직 안드로이드 13까지 패치되지 않은 기기들이 많기는 하겠지만, 뭐 금방 다 옮겨 오지 않을까 싶습니다.  물론 지구상의 모든 안드로이드 기기를 대상으로 하겠다면 달라질 수 있습니다. 
 
다만, 한국에서 팔리는 기기등은 대부분 안드로이드 13으로 패치가 되었을 거라는 믿음(?)은 강하게 당겨 옵니다. 
 

manifest
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>

manifest에  등록한 권한 목록입니다. notify을 하기 위한 권한,  wifi 상태 확인을 위한 권한등은 기능설정을 위해서 필요한 부분이고, 
 
READ_MEDIA_IMAGES 가 필수 권한입니다.  READ_EXTERNAL_STOREAGE 은 안드로이드 API 32까지만 필요하므로 이제 지워도 될 것 같습니다.  이제 데이터를 읽어 오는 부분에 대한 코드를 작성해 보겠습니다. 
 

withContext(Dispatchers.IO) {

/**
* Android [ContentProvider]로 작업할 때 핵심 개념은
* "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
* (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
* 성명.
*
* 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
* [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
* 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
*
* 이 샘플에서는 몇 개의 데이터 열만 사용하므로
* 열의 하위 집합.
*/
/**
* Android [ContentProvider]로 작업할 때 핵심 개념은
* "투영". 프로젝션은 공급자에게 요청할 열의 목록입니다.
* (정확히) SQL의 "SELECT ..." 절로 생각할 수 있습니다.
* 성명.
*
* 프로젝션을 제공하는 것은 _필수_가 아닙니다. 이 경우 `null`을 전달할 수 있습니다.
* [ContentResolver.query]에 대한 호출에서 `projection` 대신 요청하지만
* 필요한 것보다 더 많은 데이터는 성능에 영향을 미칩니다.
*
* 이 샘플에서는 몇 개의 데이터 열만 사용하므로
* 열의 하위 집합.
*/
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED,
)

/**
* `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
* 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
* 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
*
* 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
* 다음 변수에 의해 제공됩니다.
*/
/**
* `selection`은 SQL 문의 "WHERE ..." 절입니다. 가능하다
* 대신 `null`을 전달하여 이를 생략하면 모든 행이 반환됩니다.
* 이 경우 이미지를 촬영한 날짜를 기준으로 선택 항목을 사용하고 있습니다.
*
* 선택 항목에 `?`가 포함되어 있습니다. 이것은 변수를 나타냅니다.
* 다음 변수에 의해 제공됩니다.
*/
val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"

/**
* `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
* `선택`에서.
*/
/**
* `selectionArgs`는 각 `?`에 대해 채워질 값 목록입니다.
* `선택`에서.
*/
val selectionArgs = arrayOf(
dateToTimestamp(1, 1, 2000).toString()
)
selectionArgs.forEach {
Log.e("", "selectionArgs $it ${getDateTime(it)}")
}

/**
* Sort order to use. This can also be null, which will use the default sort
* order. For [MediaStore.Images], the default sort order is ascending by date taken.
*/
/**
* Sort order to use. This can also be null, which will use the default sort
* order. For [MediaStore.Images], the default sort order is ascending by date taken.
*/
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->

/**
* 반환된 [Cursor]에서 데이터를 가져오려면
* 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
*
* 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
* 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
* 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
* 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
*
* 우리의 경우 원하는 열을 정확히 알고 있기 때문에
* 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
* [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
* [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
*
* 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
* 각 행에 대해 조회할 필요가 없도록 합니다.
*/
/**
* 반환된 [Cursor]에서 데이터를 가져오려면
* 관심 있는 각 열과 일치하는 인덱스를 찾습니다.
*
* 두 가지 방법이 있습니다. 첫 번째는 방법을 사용하는 것입니다
* 열 ID를 찾을 수 없는 경우 -1을 반환하는 [Cursor.getColumnIndex]. 이것
* 코드가 요청할 열을 프로그래밍 방식으로 선택하는 경우에 유용합니다.
* 그러나 객체로 파싱하기 위해 단일 방법을 사용하고 싶습니다.
*
* 우리의 경우 원하는 열을 정확히 알고 있기 때문에
* 반드시 포함되어야 한다는 점(API 1에서 모두 지원되기 때문에)
* [Cursor.getColumnIndexOrThrow]를 사용합니다. 이 방법은
* [IllegalArgumentException] 명명된 열을 찾을 수 없는 경우.
*
* 두 경우 모두 이 방법이 느리지는 않지만 결과를 캐시하려고 합니다.
* 각 행에 대해 조회할 필요가 없도록 합니다.
*/
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateModifiedColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)

while (cursor.moveToNext()) {

// Here we'll use the column indexs that we found above.
val id = cursor.getLong(idColumn)
val dateModified =
Date(TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn)))
val displayName = cursor.getString(displayNameColumn)

/**
* 이것은 가장 까다로운 부분 중 하나입니다.
*
* 이미지에 액세스하고 있기 때문에(사용하여
* [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
* 기본 URI로 이미지의 ID를 추가합니다.
*
* 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
* [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
* 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
* 거기에 요청하십시오.
*/

/**
* 이것은 가장 까다로운 부분 중 하나입니다.
*
* 이미지에 액세스하고 있기 때문에(사용하여
* [MediaStore.Images.Media.EXTERNAL_CONTENT_URI], 이를 사용하겠습니다.
* 기본 URI로 이미지의 ID를 추가합니다.
*
* 이것은 [MediaStore.Video]로 작업할 때와 완전히 동일한 방법이며
* [MediaStore.Audio]도 마찬가지입니다. `Media.EXTERNAL_CONTENT_URI`가 무엇이든
* 아이템을 얻기 위한 쿼리가 기본이고, ID는 받을 문서입니다.
* 거기에 요청하십시오.
*/

val contentUri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)

val image = MediaStoreImage(id, displayName, dateModified, contentUri)
images += image

// For debugging, we'll output the image objects we create to logcat.
// Log.e("", "Added image: $displayName $dateModified ${TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn))}")
}
}

이 코드는 android 개발자 페이지의 open 된 코드들 중에서 storeage-samples 안에 있는 MediaStore 프로젝트에서 가지고 왔음을 밝혀 둡니다.
 
https://github.com/android/storage-samples

GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

Multiple samples showing the best practices in storage APIs on Android. - GitHub - android/storage-samples: Multiple samples showing the best practices in storage APIs on Android.

github.com

그걸 이용하면 이렇게 코드가 작성됩니다. 
 
여기에 다가 android 13에서 어떻게 적용할 것인 가 하는 부분에 대한 참조글은 처음에 인용한 글에서 참고했습니다. 
 
아무튼 이렇게 작성된 코드를 이용해서 앱 하나 만들어 playstore에 게시했습니다. ㅋ~
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710 

사진백업 - Google Play 앱

NAS 로 내 폰의 사진을 백업해 드립니다. 자동으로 해 드릴 생각 이에요

play.google.com

 





오늘의 이야기


#스하리1000명프로젝트,
Terkadang sulit untuk berbicara dengan pekerja asing, bukan?
Saya membuat aplikasi sederhana yang membantu! Anda menulis dalam bahasa Anda, dan orang lain melihatnya dalam bahasa mereka.
Ini menerjemahkan secara otomatis berdasarkan pengaturan.
Sangat berguna untuk obrolan mudah. Lihatlah ketika Anda mendapat kesempatan!
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기

장마속에서 맑은 하늘



장마 속에서 날 맑은 하늘...


 


어느 곳에서는 수마의 할큄에 모든 걸 잃어버리기도 했고...


 


세상을 떠나기도 했고...


 


가슴 아픈 사고가 있기도 했고...


 


오늘도 살아 있음에 감사하는 날이 되었기를...





오늘의 이야기

앱을 만들어 배포를 하다 보면 사용자가 업데이트를 놓치는 경우가 있을까 싶어서 앱을 실행할 때 자동으로 업데이트를 할 수 있도록 코드 구현을 하고 있습니다. 


 


물론 잠자는 시간에 충전을 하고 있다면 playstore 앱이 잘 알아서 업데이트를 해 주기도 합니다만, 그래도...


인앱 업데이트 deprecated



코드를 구현해 사용하다 보니 어느날 deprecated 되어 다는 이야기가 나옵니다. 안드로이드 버전이 올라가면서 나오는 현상이기는 하겠습니다만, 개발자 페이지를 찾아보겠습니다. 


 


개발자 페이지 설명



https://developer.android.com/reference/com/google/android/play/core/appupdate/AppUpdateManager



 


AppUpdateManager  |  Android Developers


Stay organized with collections Save and categorize content based on your preferences. Summary: Methods AppUpdateManager public interface AppUpdateManager com.google.android.play.core.appupdate.AppUpdateManager Manages operations that allow your app to ini


developer.android.com




 


어느 순간에 그걸 사용할 수 없다는 이야기가 나오고 그에 따르는 대응책을 알려 줍니다.  이제 그걸 따라해 보겠습니다.


 


변경후 코드 적용



 


코드가 한결 가벼워 졌습니다.  따라 하기도 수월해졌고요.


 


쉽게 구현해 보았습니다.





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, aplikasi yang wajib dimiliki oleh klub bulu tangkis!
👉 Match Play – Rekam Skor & Temukan Lawan 🎉
Sempurna untuk di mana saja, sendirian, bersama teman, atau di klub! 🤝
Jika Anda suka bulu tangkis, cobalah

Buka aplikasi 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

이 앱은 사용자의 휴대폰에 있는 사진(이미지)만 백업을 해 두고 싶을 때 사용할 수 있는 앱입니다.  이 앱 사용하기 위한 전제 조건은 2가지입니다. 
 

  1. WIFI 을 사용할 수 있는 가 ?
  2. NAS을 사용할 수 있거나, FTP 서버를 이용할 수 있는 가?

 
이 2가지 조건을 가지고 있지 않다면 그냥 이 앱을 지우고 벗어나시길 바랍니다. 
 

대표 이미지


 
이제 시작해 보겠습니다.  먼저 설정을 해 보겠습니다. 

기본화면


 
** 서버 접속 준비 하기
입력해야 하는 것은 서버 IP을 입력합니다.  앱에서는 동일한 WIFI 에서 사용할 수 있는 NAS의 FTP을 사용하는 것을 기준으로 앱을 구현하였기 때문에 IP 주소를 입력받습니다. 
 
사용자 ID와 패스워드는 NAS 의 서버 설정에 해당하는 ID 와 PASSWORD을 입력하면 됩니다. 
 
서버 경로는 FTP로 접속했을 때 경로를 찾아야 합니다.  그래서 windows 사용자의 경우에는 command 창을 열어서
 
ftp을 실행하고
open 192.168.0.7처럼 입력 하고 
user / password을 입력해 접속하여 경로를 찾아봅니다.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

FTP 접근해 보기


이 그림과 같이 서버 접속을 직접 해 보아야 하는 것은 서버 경로를 NAS의 설정에서 어떻게 하는 가에 따라서 달라지기 때문 이기도 합니다.   접속 경로가 확인되면 그 경로를 위 설정 하면에 서버 경로에 입력해 주면 됩니다. 
 


설정 버튼을 이용하여  입력한 서버 정보를 저장해 주어야 합니다.   이 작업이 되지 않은 경우 정상적인 진행을 할 수 없을 수 도 있습니다. 
 
이제 다음은 백업 주기를 설정 합니다.  구글에서는 배터리의 안정적인 운영을 위해서는 15분 이상으로 백그라운드 작업을 하도록 가이드하고 있습니다. 

그런데도 간격의 범위를 3분 ~ 50분으로 설정한 것은 필요에 따라서 설정을 달리 해야 하는 경우가 있을 수 도 있기 때문입니다. 
 
시간 설정까지를 마무리했다면 이제 백업 버튼을 이용하여 백업을 진행해 봅니다. 


백업버튼을 클릭했을 때 다른 부분은 
 
15분 미만으로 설정한 경우에는  백업 버튼을 클릭하기 전에  리스트 보기를 활성화해서 사진 목록이 조회되는 것으로 확인해야 합니다. 
 
이미지 목록이 없을 경우에는 알림이 표시됩니다. 
 
15분 이상으로 설정한 경우에는 백그라운드 반복 작업을 하기 때문에 리스트가 보이지 않아도 진행을 가능합니다. 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
이제 어떻게 동작을 하는지 예시 동영상을 한번 보겠습니다. 
 
 

실행하는 예시 동작

간혹 휴대장치의 성능 이슈 때문에 프로그래스바의 동작이 확인되지 않는 경우가 발생하기는 합니다.  프로그래스바 가 표시 되고 있는 동안은 전송건수에 숫자가 변하는 것을 확인할 수 있습니다. 
 
사라지는 사진을 백업해 보세요.
 
 
https://play.google.com/store/apps/details?id=com.billcorea.ftpclient0710 

사진백업 - Google Play 앱

NAS 로 내 폰의 사진을 백업해 드립니다. 자동으로 해 드릴 생각 이에요

play.google.com

 





오늘의 이야기

앱을 구현하는 동안 FTP 접속을 통해서 파일을 서버로 전송하는 앱을 구현해 보기로 하겠습니다. 


 


먼저 gradle 파일에 아래 항목을 추가합니다. FTP 서버를 활용하기 위해서는 필요합니다.


implementation 'commons-net:commons-net:3.9.0'

 


코드 구현에 필요한 코드는 아래와 같습니다. 이 명령줄을 이용해서  접속할 서버 정보를 이용해 파일을 보내 보는 것입니다.



import org.apache.commons.net.ftp.FTP
import org.apache.commons.net.ftp.FTPClient
import java.io.File
import java.io.FileInputStream

fun uploadFileToFtp(file: File, ftpServer: String, ftpUsername: String, ftpPassword: String, ftpDirectory: String) {
val ftpClient = FTPClient()
ftpClient.connect(ftpServer)
ftpClient.login(ftpUsername, ftpPassword)
ftpClient.enterLocalPassiveMode()
ftpClient.setFileType(FTP.BINARY_FILE_TYPE)
ftpClient.changeWorkingDirectory(ftpDirectory)

val inputStream = FileInputStream(file)
val fileName = file.name
ftpClient.storeFile(fileName, inputStream)
inputStream.close()

ftpClient.logout()
ftpClient.disconnect()
}

 


서버 접속 설정해 보기



화면에서는 서버 접속을 위한 정보를 입력하여 이 정보를 이용해서 FTP 서버에 접속해 보겠습니다.  그런데 일반적으로는 FTP 서버를 가지고 있지는 않을 것입니다.  그래서 저는 NAS을 활용해 보기로 했습니다. 


 


iptime NAS 의 FTP 서비스 정보 설정 하기



ip Time NAS 을 사용하는 경우이기 때문에 자신이 가지고 있는 것을 이용해서 설정을 해야 합니다.  



  • 포트번호는 기본값이 21입니다.  왜 21 이냐고 물어보신다면 그건 구글에서 찾아보시길 추천해 드립니다.

  • 모든 사용자를 익명으로 사용은 체크하지 않습니다. 그래야 사용자 관리에서 입력한 사용자를 선택해서 id와 password을 입력해 볼 수 있기 때문입니다. 

  • 다음은 공유폴더를 하나 선택해서 FTP에서 사용할 폴더로 지정해 주어야 합니다. 여기서 선택하는 경로를 실제 FTP client 프로그램에서 접속해 보려면 실제 접속해서 확인을 해 보아야 합니다. 


ftp 접속해 보기



windows의 경우 cmd 창을 열어서 ftp을 실행해 봅니다.  그다음과 같은 순서로 서버에 접속해 봅니다. 



  • open 192.168.0.7 ( 서버 IP는 NAS의 아이피와 동일합니다)

  • 사용자 이름 입력 

  • password 입력

  • dir을 입력해서 현재 경로를 확인합니다. 


위 그림과 같이 로그인 후 바로 확인되는 경로는 HDD1입니다. 위 설정에서는 BackupDocument였는 데 말입니다. 그래서  HDD1에 접근하고 나서 다시 확인 보면 이제야 해당 경로가 나오는 것을 알 수 있습니다.  그래서 경로 확인은 꼭 접속해 확인해 보아야 하는 이유입니다.


 


이상으로 오늘은 FTP 접속하는 방법과 kotlin 코드를 알아보았습니다.


 





오늘의 이야기


#스하리1000명프로젝트,
Lost in Korea? Même si vous ne parlez pas coréen, cette application vous aide à vous déplacer facilement.
Parlez simplement votre langue : il traduit, recherche et affiche les résultats dans votre langue.
Great for travelers! Prend en charge plus de 10 langues, dont l'anglais, le japonais, le chinois, le vietnamien et plus encore.
Try it now!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/03/20

오늘의 이야기

## 로또 번호 분석 및 추천

**데이터 분석 요약:**

총 20회차 데이터를 분석하여 각 회차별 번호 패턴을 파악하고, 이를 바탕으로 다음 회차 번호를 예측합니다. 분석 항목은 다음과 같습니다.

1. **연속 번호 간 간격:** 각 회차의 번호들 간 오름차순 간격.
2. **짝수/홀수 개수:** 각 회차의 짝수 및 홀수 번호 개수.
3. **총합:** 각 회차 번호 6개의 합계 및 동일 총합 출현 간격.
4. **평균:** 각 회차 번호 6개의 평균값 및 동일 평균값 출현 간격.
5. **매칭 점수:** 각 회차의 1~4번 항목 패턴을 이전 회차들과 비교하여 산출한 매칭 비율(%) 및 동일 매칭 비율 출현 간격.
6. **번호 출현 빈도:** 전체 데이터에서 각 번호가 출현한 총 빈도수.

---

**상세 분석 결과:**

* **전체 평균 총합:** 20회차 동안 번호 6개의 평균 총합은 약 **205.83**입니다. (최소 157, 최대 264)
* **전체 평균 번호 간 간격:** 번호들 간의 평균 간격은 약 **6.84**입니다.
* **가장 빈번한 짝수/홀수 패턴:** (3 Even, 3 Odd) - 10회 출현 (50%)

**지난 회차(1215회) 분석:**

* **당첨 번호:** [13,15,19,21,44,45]
* **간격:** (2,4,2,23,1)
* **짝수/홀수:** (2 Even, 4 Odd)
* **총합:** 157
* **평균:** 26.17

---

**최종 추천 번호 및 상세 근거:**

다음 회차(1216회)를 위한 5가지 추천 조합입니다. 모든 조합은 지난 10회차 당첨 번호와 중복되지 않으며, 1부터 45 사이의 숫자로 구성됩니다.

### 1. 추천 [01,27,31,35,38,41]
* **선정 근거:** 지난 20회차 동안 가장 많이 출현한 6개의 숫자(1, 27, 38, 31, 35, 41)를 조합했습니다. 통계적으로 가장 높은 출현 빈도를 가진 숫자들로 구성되어 있습니다. 이 조합의 총합은 173으로, 전체 평균 총합(205.83)보다 낮아 최근 총합 트렌드(157)와 유사하거나 약간 높은 수준입니다. 짝수/홀수 비율은 (2 Even, 4 Odd)입니다.

### 2. 추천 [04,26,30,25,31,35]
* **선정 근거:** 지난 회차(1215회)의 짝수 2개, 홀수 4개 패턴에서 벗어나 가장 빈번하게 나타나는 짝수 3개, 홀수 3개의 균형 패턴으로 회귀할 것을 예측하여 구성했습니다. 최다 빈도수 숫자 풀에서 짝수 3개와 홀수 3개를 선택하여 구성되었습니다. 이 조합의 총합은 171, 평균은 28.5입니다.

### 3. 추천 [06,16,24,32,36,40]
* **선정 근거:** 지난 회차의 총합(157)이 전체 평균 총합(약 205.83)보다 낮았으므로, 평균 총합(205.83)에 가까운 약 200~220 범위의 숫자로 구성했습니다. 번호 간 평균 간격도 전체 평균 간격(약 6.84)과 유사하게 (4,8,8,4,4)로 배치하여 극단적인 패턴을 피했습니다. 짝수 6개, 홀수 0개의 짝수 강세 패턴입니다.

### 4. 추천 [10,18,22,28,38,42]
* **선정 근거:** 지난 회차의 번호들이 주로 중소 번호대에 집중된 경향(13,15,19,21)이 있었고 짝수 2, 홀수 4였습니다. 이를 역전하여 짝수 6개, 홀수 0개의 비율로, 번호 범위도 중후반대에 고루 분포되도록 큰 숫자들을 포함하여 구성했습니다. 총합은 158로 지난 회차와 유사하며, 평균은 26.33으로 소폭 상승했습니다.

### 5. 추천 [03,12,17,20,33,43]
* **선정 근거:** 최근 10회차 당첨 번호에서 출현 빈도가 다소 낮지만, 전체적으로는 꾸준히 출현하는 중간 빈도수의 숫자들과 최다 빈도수 숫자를 혼합했습니다. 번호 간 간격이 너무 좁거나 넓지 않도록 (9,5,3,13,10) 적절히 배분하여 다양한 범위의 숫자를 커버하도록 했습니다. 총합은 128로 낮은 편이며, 짝수 2개, 홀수 4개 조합입니다.

---

**지난 회차(1215회) 분석과 추천 조합 비교:**

| 항목 | 1215회 당첨 번호 | 추천 1번 [01,27,31,35,38,41] | 추천 2번 [04,26,30,25,31,35] | 추천 3번 [06,16,24,32,36,40] | 추천 4번 [10,18,22,28,38,42] | 추천 5번 [03,12,17,20,33,43] |
| :------------- | :-------------------- | :-------------------------- | :-------------------------- | :-------------------------- | :-------------------------- | :-------------------------- |
| **당첨 번호** | [13,15,19,21,44,45] | [01,27,31,35,38,41] | [04,25,26,30,31,35] | [06,16,24,32,36,40] | [10,18,22,28,38,42] | [03,12,17,20,33,43] |
| **총합** | 157 | 173 | 171 | 194 | 158 | 128 |
| **평균** | 26.17 | 28.83 | 28.5 | 32.33 | 26.33 | 21.33 |
| **짝수/홀수** | 2 Even, 4 Odd | 2 Even, 4 Odd | 3 Even, 3 Odd | 6 Even, 0 Odd | 6 Even, 0 Odd | 2 Even, 4 Odd |
| **간격 패턴** | (2,4,2,23,1) | (26,4,4,3,3) | (21,1,4,1,4) | (10,8,8,4,4) | (8,4,6,10,4) | (9,5,3,13,10) |
| **주요 특징** | 낮은 총합, 중소 번호대, 높은 간격 | 고빈도수, 낮은 총합, 일부 큰 간격 | 균형 잡힌 짝홀, 낮은 총합, 고빈도수 | 짝수 강세, 평균 총합, 고른 간격 | 짝수 강세, 낮은 총합, 중대 번호 | 고빈도/저빈도 혼합, 낮은 총합, 다양한 간격 |

**비교 분석 요약:**

* **총합 및 평균:** 지난 1215회차의 총합(157)과 평균(26.17)이 전체 평균보다 낮았기 때문에, 추천 조합들은 이와 유사한 낮은 총합/평균을 유지하거나(추천 1, 2, 4, 5) 전체 평균에 가까운 총합/평균(추천 3)을 가지도록 구성했습니다.
* **짝수/홀수 균형:** 1215회차는 (2 Even, 4 Odd) 패턴을 보였습니다. 추천 조합들은 이 패턴을 유지하거나(추천 1, 5), 가장 빈번한 (3 Even, 3 Odd) 패턴(추천 2), 또는 반전 패턴인 (6 Even, 0 Odd) 패턴(추천 3, 4)을 제시하여 다양한 가능성을 고려했습니다.
* **번호 간 간격:** 1215회차는 (2,4,2,23,1)과 같이 '23'이라는 매우 큰 간격이 특징적이었습니다. 추천 조합들은 이처럼 극단적인 간격보다는 전체 평균 간격(약 6.84)에 가깝거나, 특정 구간에 집중되는 경향을 보이도록 구성했습니다. 예를 들어, 추천 3번은 비교적 고른 간격(10,8,8,4,4)을 가지며, 추천 5번은 큰 간격(13,10)을 일부 포함하지만 1215회차의 23만큼 극단적이지는 않습니다.
* **번호대 분포:** 1215회차는 [13,15,19,21]과 같은 중소 번호와 [44,45]의 큰 번호가 혼합된 형태였습니다. 추천 조합들은 최다 빈도수 숫자를 기반으로 하되, 중소 번호, 중대 번호, 고른 분포 등 다양한 번호대 조합을 시도하여 예측의 폭을 넓혔습니다.



사용하는 예시 영상 보기
이 앱이 궁금 하다면, 아래 링크에서 설치할 수 있습니다.
로또 645






오늘의 이야기

오늘은 앱의 하단  탐색바를 만들다 고민이 생긴 걸 해소하는 이야기를 적어 봅니다.  


https://medium.com/@banmarkovic/jetpack-compose-clear-back-stack-popbackstack-inclusive-explain ed-14ee73a29df5



 


Jetpack Compose clear back stack, popBackStack inclusive explained


Understand the popUpTo and inclusive parameter for navigation-compose lib, and learn how to clear back stack.


medium.com




이 이야기의 출처는 위에 링크를 참조해 주세요.   이야기의 중요 논점은 내비게이션 바를 이용해서 화면 간의 이동을 하게 되는 경우 어떻게 뒤로 가기를 정리할 것인가 하는 문제와 만나게 되면서 시작됩니다. 


 


bottom bar navigation 은 stack에 메뉴을 하나씩 넣어 주면서 화면을 이용 합니다. stack 이라는 것이 FILO 라는 규칙을 따라가기 때문에 차곡 차곡 쌓아 두었다가 하나씩 빠져 나가는 것으로 처리를 하게 됩니다. 


 


popbackStack 의 처리 예시



위 그림과 같이  stack 에 Login, Home, Profile이라는 화면을 하나씩 선택해 들어갔을 때 어떻게 해서 정리를 해 두면 좋을까 하는 부분에 대한 고민이 생겨 납니다. 


 


사용자들은 앱을 사용하다가 뒤로 가기 버튼을 클릭하는 것으로 앱을 종료하려고 합니다.  (물론 그냥 닫아 버리기도 하기는 합니다.) 그랬을 때 스택에 들어 있는 것들이 많았다면 화면을 하나하나씩 빼내어 뒤로 가기를 실행하다가 stack에서 다 빠지게 되면 그때서야 앱을 종료하는 onBackPress Call Back을 타게 됩니다. 


 


그래서 저는 저 이야기를 읽고 나서 앱의 bottom bar navigation을 구현하면서 다음과 같이 탐색하는 방법을 변경해 보았습니다. 


 



dropletButtons.forEachIndexed { index, it ->
DropletButton(
modifier = Modifier.fillMaxSize(),
isSelected = dataViewModel.selectedItem.value == index,
onClick = {
Log.e("", "index = $index")
dataViewModel.selectedItem.value = index
naviController.navigate(it.direction) {
popUpTo(naviController.graph.id) {
inclusive = true
}
}
},
icon = it.icon,
size = 26.dp,
contentDescription = stringResource(id = it.description),
dropletColor = Color.White,
animationSpec = tween(durationMillis = Duration, easing = LinearEasing)
)
}


navigate(다음화면) { popUpTo(화면 ID) { inclusive = true } } 이렇게 호출하는 것으로 해서 stack에 들어가는 것들을 지우면서 화면 이동을 시도 했습니다.   결국에는 stack 에 저장된 것이 마지막 하나뿐인 상태가 되는 것입니다. 


 


그랬더니, 뒤로 가기 버튼을 클릭하면 바로 onBack Press Call Back으로 들어옵니다.  이제 한 번에 뒤로 가기 버튼을 이용한 앱 종료 액션을 처리해 볼 수 있었습니다. 


 


오늘도 즐~ 코딩...


 


이제 얼마 남지 않은 장맛비를 잘 달래 보내야 할 때가 된 것 같습니다.





오늘의 이야기

오늘만에 제대로 된 개발 이야기를 적어 보겠습니다.  한동안 외주 프로젝트에 참여를 하고 있어서 마음에 여유가 없었던 탓이기도 하고, 게으른 탓이기도 하겠지만, 개발이야기는 적어 보지 못했습니다.   그동안 눈팅(?)하던 jetpack compose 이야기에서 애니메이션이 가미된 navigation bar을 만들어 보겠습니다. 


 


https://medium.com/proandroiddev/jetpack-compose-tutorial-animated-navigation-bar-354411c679c8



 


Jetpack Compose Tutorial: Animated Navigation Bar


How to implement a navigation bar with smooth custom animations


proandroiddev.com




 원작자의 이야기는 여기에서 보실 수 있습니다.  영문 버전으로 이해를 해야 하기에 나름의 고충(?)도 있을 수 있으나, 이런 정도는 번역기가 잘 번역을 해 줍니다. 


 


이글의 이야기들을 읽다 보면 마치 무슨 수학 함수를 풀어내야만 하는 것 처럼 보이기도 합니다.  아무튼... 다이내믹한 뭔가를 얻어 내는 데, 무한한 노력이 필요해 보이기는 합니다. 


 


//AnimatedNavigationBar
implementation("com.exyte:animated-navigation-bar:1.0.0")

애니메이션 네비게이트를 활용하기 위해서 gradle 파일에 추가를 했습니다. 


 


이번에는 메뉴에 표시할 항목등을 만들어 보도록 하겠습니다.  여러개를 만들어 낼 수 도 있지만, 예시에서는 메뉴 항목이 5개인 것을 구현해 보겠습니다.


 


import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Stable
import com.billcorea.bluetooth627.R

@Stable
data class Item(
@DrawableRes val icon: Int,
var isSelected: Boolean,
@StringRes val description: Int,
val animationType: ColorButtonAnimation = BellColorButton(
tween(500),
background = ButtonBackground(R.drawable.plus)
),
)

val dropletButtons = listOf(
Item(
icon = R.drawable.home,
isSelected = false,
description = R.string.Home
),
Item(
icon = R.drawable.bell,
isSelected = false,
description = R.string.Bell
),
Item(
icon = R.drawable.message_buble,
isSelected = false,
description = R.string.Message
),
Item(
icon = R.drawable.heart,
isSelected = false,
description = R.string.Heart
),
Item(
icon = R.drawable.outline_menu_24,
isSelected = false,
description = R.string.Menu
),

)

이렇게 메뉴에 들어갈 항목을 만들어 보았습니다. 다만, 여기서 알 수 없는 것은 BellColorButton 이 위에서 implemention에서 가져오지 못한 다는 것입니다.  알 수 없는 일이기도 합니다.  아무튼 그것에 관련된 코드 부분은 github에 있는 project 파일에서 가져왔음을 밝혀 둡니다. 


 


import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.exyte.animatednavbar.utils.rotationWithTopCenterAnchor
import kotlin.math.PI
import kotlin.math.sin

class BellColorButton(
override val animationSpec: FiniteAnimationSpec<Float> = tween(),
override val background: ButtonBackground,
private val maxDegrees: Float = 30f,
) : ColorButtonAnimation(animationSpec, background) {

@Composable
override fun AnimatingIcon(
modifier: Modifier,
isSelected: Boolean,
isFromLeft: Boolean,
icon: Int,
) {
val rotationFraction = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = animationSpec,
label = "rotationFractionAnimation"
)

val color = animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.LightGray,
label = "colorAnimation"
)

Icon(
modifier = modifier
.rotationWithTopCenterAnchor(
if (isSelected) degreesRotationInterpolation(
maxDegrees,
rotationFraction.value
) else 0f
),
painter = painterResource(id = icon),
contentDescription = null,
tint = color.value
)
}

private fun degreesRotationInterpolation(maxDegrees: Float, fraction: Float) =
sin(fraction * 2 * PI).toFloat() * maxDegrees
}

import androidx.annotation.DrawableRes
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset

data class ButtonBackground(
@DrawableRes val icon: Int,
val offset: DpOffset = DpOffset.Zero
)

@Stable
abstract class ColorButtonAnimation(
open val animationSpec: FiniteAnimationSpec<Float> = tween(10000),
open val background: ButtonBackground,
) {
@Composable
abstract fun AnimatingIcon(
modifier: Modifier,
isSelected: Boolean,
isFromLeft: Boolean,
icon: Int,
)
}

이렇게 이번  project 에서 가져다 사용할 것만 추려서 가져왔습니다.  하단바 메뉴를 구현해 보겠습니다.


 


import android.util.Log
import android.view.animation.OvershootInterpolator
import androidx.annotation.StringRes
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.billcorea.bluetooth627.DoubleDuration
import com.billcorea.bluetooth627.Duration
import com.billcorea.bluetooth627.R
import com.billcorea.bluetooth627.composes.destinations.AuthorSettingScreenDestination
import com.billcorea.bluetooth627.composes.destinations.DirectionDestination
import com.billcorea.bluetooth627.composes.destinations.MainScreenDestination
import com.billcorea.bluetooth627.ui.theme.Purple
import com.billcorea.bluetooth627.ui.theme.SoftBlue
import com.billcorea.bluetooth627.ui.theme.SoftBlue40
import com.exyte.animatednavbar.AnimatedNavigationBar
import com.exyte.animatednavbar.animation.balltrajectory.Parabolic
import com.exyte.animatednavbar.animation.indendshape.Height
import com.exyte.animatednavbar.animation.indendshape.shapeCornerRadius
import com.exyte.animatednavbar.items.dropletbutton.DropletButton
import com.ramcosta.composedestinations.navigation.navigate

@Composable
fun BottomBar( naviController : NavHostController) {

var selectedItem by remember { mutableStateOf(0) }
AnimatedNavigationBar(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 4.dp)
.height(90.dp),
selectedIndex = selectedItem,
barColor = SoftBlue,
ballColor = SoftBlue,
cornerRadius = shapeCornerRadius(25.dp),
ballAnimation = Parabolic(tween(Duration, easing = LinearOutSlowInEasing)),
indentAnimation = Height(
indentWidth = 56.dp,
indentHeight = 15.dp,
animationSpec = tween(
DoubleDuration,
easing = { OvershootInterpolator().getInterpolation(it) })
)
) {
dropletButtons.forEachIndexed { index, it ->
DropletButton(
modifier = Modifier.fillMaxSize(),
isSelected = selectedItem == index,
onClick = {
Log.e("", "index = $index")
selectedItem = index
when (selectedItem) {
0 -> {
naviController.navigate(BottomMenuItems.Main.direction)
}
4 -> {
naviController.navigate(BottomMenuItems.Setting.direction)
}
else ->{

}
}
},
icon = it.icon,
contentDescription = stringResource(id = it.description),
dropletColor = Color.White,
animationSpec = tween(durationMillis = Duration, easing = LinearEasing)
)
}
}
}

enum class BottomMenuItems(
val direction : DirectionDestination,
@StringRes val label : Int,
) {
Main(MainScreenDestination, R.string.Main),
Setting(AuthorSettingScreenDestination, R.string.Setting),
}

 


아직은 테스트를 위한 것이라 메뉴가 2개만 들어 있는 것 같은 느낌인 데, 실행해 보면 다음과 같이 구현이 되는 것을 알 수 있습니다.


 


 



애니메이션 네비게이트


이렇게 오늘도 따라 하기 코딩 하나를 해 보았습니다.  조금 아쉬운 부분은 네비게이션 구현을 함에 있어서 메뉴에 label 이 나오지 않는다는 것이 하단바 메뉴로 사용하기에는 부족한 부분이 아닐까 하는 생각을 해 보았습니다.   아무튼 오늘도 끝.


기본 이미지



 





오늘의 이야기

이전 글에서 정리할 것처럼 java에서 kotlin으로 이전을 했습니다.  그러고 나서 보기 시작했는 데,  DefaultSharedPrefernces의 사용할 수 없는 환경으로 변경이 된 것을 알게 되었습니다.    이전 prefs = Prefere...