-
Notifications
You must be signed in to change notification settings - Fork 2
Description
🌜 미라클 모닝으로 시작하는 당신의 의미있는 아침, meaning 🌝
👇 meaning_Android 👇
🎴 About US
| 진수 | 효송 | 형준 |
|---|---|---|
|
|
|
|
Contact:[email protected] GitHub:jinsu4755 |
Contact:[email protected] GitHub:hyooosong |
Contact: [email protected] GitHub:LEE-HYUNGJUN |
🦂진수
- 타임스템프 카메라
- 로그인
- 온보딩
- 서버 연결 로직 구현
🎅 효송
- 그룹 탭
- 캘린더 뷰
- sharedPreferences 싱글톤 객체 구현
👨 👧 형준
- 홈 메인페이지
- 카드 뷰 애니메이션
- 마이 피드, 그룹 피드
- 피드 상세보기 뷰
🏆 Meeting Log
🎴 미닝언즈 안드로미닝 회의록
📝 List
1. [Service]
2. [Andromeaning Development Environment]
3. [Work Flow]
4. [Dependencies]
5. [Team Role]
- [Andromeaning Conventions]
- [Andromeaning Coding Style]
- [Code Review Guideline]
- [Git]
6. [meaning Tech Stack]
7. [Packaging]
8. [Main Feature Codes & Methods]
💫 Service about meaning
모든 것은 바뀔 수 있고 나 역시 무언가를 바꿀 수 있습니다.
기상시간이 달라진다면, 당신도 변할 수 있습니다.
‘내’가 눈 뜨는 시간이 아닌, ‘해’가 뜨는 시간부터 하루를 시작하는 미라클 모닝.
미닝을 통해 미라클 모닝에 도전하며 당신만의 의미있는 아침을 만들어 나가보세요.
일찍 일어나는 습관으로 하루를 길게 보내면, 성장의 발판을 마련할 수 있습니다.
미닝과 함께 체계적인 계획을 세우고 이를 규칙적으로 실천하면서 성취감을 얻어보세요.
성장지향적인 그룹원과 목표를 공유한다면 우리는 함께, 더 멀리 갈 수 있습니다.
💫 Development Environment
💫 Work Flow
💫 Dependencies
| Name | Gradle |
| kotlin | org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version |
| Android KTX | implementation 'androidx.core:core-ktx:1.3.2 |
| Design | androidx.appcompat:appcompat:1.2.0 |
| com.google.android.material:material:1.2.1 | |
| androidx.constraintlayout:constraintlayout:2.0.4 | |
| androidx.legacy:legacy-support-v4:1.0.0 | |
| viewModel init support | androidx.activity:activity-ktx:1.1.0 |
| androidx.fragment:fragment-ktx:1.2.5 | |
| LiveData and ViewModel (Arch components) | androidx.lifecycle:lifecycle-livedata-ktx:2.2.0 |
| androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0 | |
| retrofit | com.squareup.retrofit2:retrofit:2.9.0 |
| com.squareup.retrofit2:converter-gson:2.9.0 | |
| com.squareup.okhttp3:logging-interceptor:4.9.0 | |
| Gson | com.google.code.gson:gson:2.8.6 |
| CameraX core library using camera2 implementation | androidx.camera:camera-core:$camerax_version |
| androidx.camera:camera-camera2:$camerax_version | |
| CameraX Lifecycle Library | androidx.camera:camera-lifecycle:$camerax_version |
| CameraX View class | androidx.camera:camera-view:1.0.0-alpha20 |
| Test | junit:junit:4.13.1 |
| androidx.test.ext:junit:1.1.2 | |
| androidx.test.espresso:espresso-core:3.3.0 | |
| image load | com.github.bumptech.glide:glide:4.11.0 |
| com.github.bumptech.glide:compiler:4.11.0 | |
| splash lottie | com.airbnb.android:lottie:3.5.0 |
-
Material Design Component
구글 Material Design을 쉽게 사용할 수 있는 구현체 제공 라이브러리, UI에 사용하였습니다. -
Glide
url 형식 이미지를ImageView에 표시하기 위해 사용하였습니다. -
AAC Lifecycle
Live Data, Lifecycle, ViewModel 과 같은 생명주기와 연동된 컴포넌트들과 클래스 제공 -
Coroutine
비동기 작업을 위한 라이브러리로 타임스템프 카메라에서 실시간으로 시간의 변경을 비동기로 처리하기 위해 사용. -
Activity, Fragment ktx
ViewModel을 onCreate에서 초기화 하는경우 여러번 생성혹은 상태 손실을 막기 위해 lazy delegate 작업으로 viewModel 객체를 받아서 사용. -
Retrofit
안드로이드 REST API 통신 라이브러리. AsyncTask 없이 Background Thread에서 실행되며 callback을 통해 Main Thread에서의 UI 업데이트를 간단하게 할 수 있도록 제공. 서버 통신을 위해 사용. -
CameraX
CameraX는 카메라 앱 개발을 더 쉽게 할 수 있도록 만들어진 Jetpack 지원 라이브러리, 타임스템프 카메라 부분에서 사용. -
Lottie
Splash 및 Login 배경으로 사용
💫 Team Role
-
🌱 Andromeaning Conventions
-
🌱 Andromeaning Coding Style
-
🌱 Code Review Guideline
-
🌱 Git
feat: 새로운 기능 추가하기fix: 버그 수정하는 경우style: 색상 변경, 폰트 변경 등이 있는 경우refactor: 코드 리팩토링 하는 경우upload: 파일 생성하는 경우docs: 문서 수정하는 경우
💫 meaning Tech Stack
MVC와 MVVM의 혼합 아키텍처로 개발 하였습니다.
- ** AAC DataBinding, ViewModel **
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_login)
binding.viewModel = loginViewModel
binding.lifecycleOwner = this
initView()
}private val loginViewModel: LoginViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T =
LoginViewModel(MeaningStorage.getInstance(this@LoginActivity)) as T
}
}-
Coroutine - 비동기 작업
fun runCurrentTimer() = viewModelScope.launch() { while (isEnableTimer) { _currentTime.value = SimpleDateFormat(TIME_FORMAT, Locale.KOREA) .format(System.currentTimeMillis()) _currentDate.value = SimpleDateFormat(DATE_FORMAT, Locale.KOREA) .format(System.currentTimeMillis()) delay(10000) } }
-
CameraX
private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) cameraProviderFuture.addListener( cameraProvideFutureListener(cameraProviderFuture), getMainExecutor() ) } private fun cameraProvideFutureListener( cameraProviderFuture: ListenableFuture<ProcessCameraProvider> ) = Runnable { val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() val preview = getCameraPreview() val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA setImageCapture() try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture) } catch (failBindException: Exception) { Log.e(TAG, "Use case binding failed", failBindException) } } private fun getCameraPreview(): Preview = Preview.Builder() .build() .also { it.setSurfaceProvider(binding.cameraViewFinder.surfaceProvider) } private fun setImageCapture() { imageCapture = ImageCapture.Builder() .build() } private fun getMainExecutor() = ContextCompat.getMainExecutor(requireContext()) private fun takePhoto() { val imageCapture = imageCapture ?: return imageCapture.takePicture( getMainExecutor(), getImageCapturedCallback() ) } private fun getImageCapturedCallback(): TimeStampCameraCallback = TimeStampCameraCallback().apply { setOnCaptureSuccessListener { imageCaptureEvent(it) } } private fun imageCaptureEvent(image: Bitmap) { cameraViewModel.image = image cameraViewModel.isEnableTimer = false (requireActivity() as TimeStampCameraActivity).changeFragment( CameraResultFragment(), null ) }
💫 Packaging
🌅meaning.morning
┣ 📂data
┣ 📂network
┃ ┣ 📂request
┃ ┣ 📂response
┣ 📂presentation
┃ ┣ 📂adapter
┃ ┃ ┣ 📂feed
┃ ┃ ┣ 📂group
┃ ┃ ┣ 📂home
┃ ┣ 📂camera
┃ ┣ 📂group
┃ ┃ ┣ 📂feed
┃ ┣ 📂home
┃ ┃ ┣ 📂card
┃ ┃ ┣ 📂feed
┃ ┣ 📂login
┃ ┗ 📂onboarding
┗📂utils
💫 Main Feature Codes & Methods
✔ sharedPreference 싱글턴 작성
object를 사용하지 않고 작성하기.
Multi-Thread Safe하도록 만들기.
SharedPreference지만 보다 직관적인 이름을 사용하기.
class MeaningStorage(context: Context) {
/* ... */
companion object {
private var instance: MeaningStorage? = null
fun getInstance(context: Context) = instance ?: synchronized(this) {
instance ?: MeaningStorage(context).apply {
instance = this
}
}
}
}✔ TimeStamp Camera
- Camera Permission
private fun initTimeStampCamera() {
if (allPermissionGranted()) {
loadCameraView()
return
}
requestPermission()
}
private fun allPermissionGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(
applicationContext,
it
) == PackageManager.PERMISSION_GRANTED
}
private fun requestPermission() {
ActivityCompat.requestPermissions(
this,
REQUIRED_PERMISSIONS,
CameraViewModel.REQUEST_CODE_PERMISSIONS
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CameraViewModel.REQUEST_CODE_PERMISSIONS) {
permissionResponseEvent()
}
}
private fun permissionResponseEvent() {
if (allPermissionGranted()) {
loadCameraView()
return
}
permissionDeniedEvent()
}
private fun permissionDeniedEvent() {
showToast("권한을 승인하지 않으면 당신의 미라클 모닝을 기록할 수 없어요!")
finish()
}
private fun loadCameraView() {
changeFragment(CameraFragment())
}
private fun changeFragment(initFragment: Fragment) {
val transaction = supportFragmentManager.beginTransaction()
transaction.apply {
replace(R.id.fragment_camera, initFragment)
commit()
}
} private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
cameraProviderFuture.addListener(
cameraProvideFutureListener(cameraProviderFuture),
getMainExecutor()
)
}
private fun cameraProvideFutureListener(
cameraProviderFuture: ListenableFuture<ProcessCameraProvider>
) = Runnable {
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val preview = getCameraPreview()
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
setImageCapture()
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
} catch (failBindException: Exception) {
Log.e(TAG, "Use case binding failed", failBindException)
}
}
private fun getCameraPreview(): Preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(binding.cameraViewFinder.surfaceProvider)
}
private fun setImageCapture() {
imageCapture = ImageCapture.Builder()
.build()
}
private fun getMainExecutor() = ContextCompat.getMainExecutor(requireContext())
private fun takePhoto() {
val imageCapture = imageCapture ?: return
imageCapture.takePicture(
getMainExecutor(),
getImageCapturedCallback()
)
}
private fun getImageCapturedCallback(): TimeStampCameraCallback =
TimeStampCameraCallback().apply {
setOnCaptureSuccessListener { imageCaptureEvent(it) }
}
private fun imageCaptureEvent(image: Bitmap) {
cameraViewModel.image = image
cameraViewModel.isEnableTimer = false
/* ... */
}
다음과 같이 만들어진 카메라를 뷰모델에 저장하여 결과 창으로 넘기고 결과창에서는 해당 뷰를 Bitmap으로 변환하여 저장한다.
class TimeStampImageCreator(private val context: Context) {
/* ... */
fun saveOf(viewGroup: ConstraintLayout) {
val width = viewGroup.width
val height = viewGroup.height
removeViewEvent(viewGroup)
val bitmapBuffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmapBuffer)
viewGroup.draw(canvas)
saveImage(bitmapBuffer)
}
private fun removeViewEvent(viewGroup: ConstraintLayout) {
viewGroup.apply {
clearFocus()
isPressed = false
invalidate()
}
}
private fun getOutputDirectory(): File {
val mediaDir = context.externalMediaDirs.firstOrNull()?.let {
File(it, context.resources.getString(R.string.app_name)).apply {
mkdirs()
}
}
return if (mediaDir != null && mediaDir.exists()) mediaDir else context.filesDir
}
private fun saveImage(bitmapBuffer: Bitmap) {
photo = getPhotoFile()
try {
val outputStream = FileOutputStream(photo)
bitmapBuffer.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
outputStream.close()
galleryAddPicture()
} catch (errorMessage: FileNotFoundException) {
errorMessage.stackTrace
} catch (errorMessage: IOException) {
errorMessage.stackTrace
} finally {
bitmapBuffer.recycle()
}
}
private fun getPhotoFile() = File(
getOutputDirectory(),
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.KOREA
).format(System.currentTimeMillis()) + ".jpeg"
)
}만든 파일은 글쓰기 화면으로 넘긴다.
✔ MyFeedPictureAdapter
아이템 클릭 이벤트를 인터페이스로 분리.
class MyFeedPictureAdapter : RecyclerView.Adapter<MyFeedPictureAdapter.MyFeedPictureViewHolder>() {
var data = mutableListOf<MyFeedPictureData>()
private lateinit var itemClickListener : ItemClickListener
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyFeedPictureViewHolder {
val binding = FeedItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MyFeedPictureViewHolder(binding)
}
override fun getItemCount(): Int {
return data.size
}
override fun onBindViewHolder(holder: MyFeedPictureViewHolder, position: Int) {
holder.onBind(data[position])
holder.itemView.setOnClickListener {
itemClickListener.onClick(it,position)
}
}
fun submitData(list : List<MyFeedPictureData>){
data.addAll(list)
notifyDataSetChanged()
}
class MyFeedPictureViewHolder(val binding: FeedItemListBinding) : RecyclerView.ViewHolder(binding.root) {
fun onBind(data: MyFeedPictureData) {
binding.feedItemList = data
}
}
interface ItemClickListener{
fun onClick(view : View, position: Int)
}
fun setItemClickListener(itemClickListener: ItemClickListener){
this.itemClickListener = itemClickListener
}
}💫 Layout 관련
-
Layout 사용
데이터 바인딩으로 사용으로 모든 뷰의 최상위가 Layout 태그 아래 있음
-
coordinatorlayout, NestedScrollView 사용
스크롤 이벤트 발생시 behavior를 이용하여 뷰의 변경을 하기 위해서 사용.- fragment_group.xml - activity_my_feed_main.xml - activity_group_settting.xml -
단순 도형 에셋 - 캘린더 뷰 아래 원
radius 확인이 불가능하여 디자이너에게 요청후 에셋으로 받기로함
- HomeFragment
-
절대 크기 지정
- feed_item_list.xml - dialog_group_recycler.xml - dialog_group_detail.xml - fragment_home.xml- feed_item_list : 피드 아이템으로 들어올 사진 크기가 기기별로 다를 경우를 따라 절대 크기 지정
- dialog : 화면 비율에 따라가 아닌 다이얼로그 창의 크기 고정을 위해서 사용
- fragement_home.xml : > 모양 에셋 크기가 너무 작다는 요청에 절대크기로 약간 크기 증가 지정.










