diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7865aae6..cb15ee1f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,35 +7,25 @@ assignees: '' --- -### 발견한 문제 +# ⚠️ Bug Report ---- +## 발견한 문제 - -### 스크린샷 ---- +## 스크린샷 - -### 플랫폼(Android, iOS, Web) ---- -- 플랫폼: -- 디바이스: -- OS: -- 브라우저: +## 플랫폼(Android) -### 기타 정보 +- 디바이스: ---- +## 기타 정보 -- 앱 버전: +- 앱 버전: diff --git a/.github/workflows/qa_apk.yml b/.github/workflows/qa_apk.yml new file mode 100644 index 00000000..d0319d9f --- /dev/null +++ b/.github/workflows/qa_apk.yml @@ -0,0 +1,88 @@ +name: Android APK Build and Slack Upload + +on: + workflow_dispatch: # 버튼을 누를 시 실행되도록 설정 + inputs: + environment: + description: 'Select environment' + required: true + default: 'qa' + type: choice + options: + - qa + - production + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Create Local Properties + run: touch local.properties + + - name: Access Local Properties + env: + DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }} + PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }} + KAKAO_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + run: | + echo DEV_BASE_URL=\"$DEV_BASE_URL\" >> local.properties + echo PROD_BASE_URL=\"$PROD_BASE_URL\" >> local.properties + echo KAKAO_NATIVE_APP_KEY=$KAKAO_APP_KEY >> local.properties + + - name: Generate google-services.json + run: | + echo "$GOOGLE_SERVICE" > app/google-services.json.b64 + base64 -d -i app/google-services.json.b64 > app/google-services.json + env: + GOOGLE_SERVICE: ${{ secrets.GOOGLE_SERVICE }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload APK + uses: actions/upload-artifact@v3 + with: + name: app + path: app/build/outputs/apk/debug/app-debug.apk + + - name: Slack - Send Msg + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + fields: workflow,commit,repo,author,job,ref,took + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + - name: Slack - Upload APK + if: github.event_name == 'workflow_dispatch' + uses: MeilCli/slack-upload-file@v4 + with: + slack_token: ${{ secrets.SLACK_TOKEN }} + channel_id: ${{ secrets.SLACK_CHANNEL }} + initial_comment: 'APK 빌드가 완료되었습니다.' + file_type: 'apk' + file_name: 'app-debug.apk' + file_path: './app/build/outputs/apk/debug/app-debug.apk' diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml index c841112d..bc7f0c4e 100644 --- a/.github/workflows/release_tag.yml +++ b/.github/workflows/release_tag.yml @@ -1,9 +1,12 @@ name: Release Tag -on: - push: - branches: [ "production" ] - +#트리거 요소 +on: + pull_request: + branches: + - production + types: + - closed jobs: build: diff --git a/README.md b/README.md index 729eb24b..74182d9c 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,30 @@ ## 📌 Project Init - 숭실대 학식 리뷰 앱 -- 기간: 2023.03 ~ -- PlayStore : [EAT-SSU](https://play.google.com/store/apps/details?id=com.eassu.android) +- 기간: 2023.03 ~ +- [PlayStore 바로가기](https://play.google.com/store/apps/details?id=com.eassu.android) + +![그래픽이미지](https://github.com/user-attachments/assets/e89f46bb-dece-45a9-a453-a00bf9d463cd) + + ## 🛠 Tech Stack -- `Kotlin` -- `MVVM` + `Clean Architecture` -- `Coroutine` -- `Flow` -- `UiState` -- `Hilt` +- Kotlin +- MVVM +- Clean Architecture +- Coroutine + Flow +- UiState +- Hilt +- xml + viewBinding (+dataBinding) +- Retrofit2 + Okhttp3 +- Gilde +- KaKao OAuth SDK +- Firebase RemoteConfig, Crashlytics ## 🤔 Not Yet.. -- `Modularization` -- `Jetpack Compose` -- `DataSource` + `Repository Pattern` +- Modularization +- Jetpack Compose +- DataSource + Repository Pattern ## 📄 Package ``` @@ -44,7 +53,7 @@ com.eatssu.android ## 🤖 Android -- Android Studio : Android Studio Hedgehog | 2023.1.1 Patch 2 +- Android Studio : Android Studio Koala | 2024.1.1 - JDK : 17 - minSDK : 23 - targetSDK : 34 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5562fa2e..1e39c1e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,12 +15,15 @@ android { namespace = "com.eatssu.android" compileSdk = 34 + // S8: API 28 + // S21: API 33 defaultConfig { applicationId = "com.eatssu.android" minSdk = 23 targetSdk = 34 - versionCode = 19 - versionName = "2.0.0" + versionCode = 1 + versionName = "2.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -138,14 +141,14 @@ dependencies { implementation(libs.lifecycle.viewmodel) implementation(libs.lifecycle.livedata) - // Firebase implementation(libs.play.services.base) - implementation(libs.firebase.config) + + // Firebase implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config) implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) - // Timber for logging implementation(libs.timber) } diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json deleted file mode 100644 index e9c9601b..00000000 --- a/app/release/output-metadata.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 3, - "artifactType": { - "type": "APK", - "kind": "Directory" - }, - "applicationId": "com.eatssu.android", - "variantName": "release", - "elements": [ - { - "type": "SINGLE", - "filters": [], - "attributes": [], - "versionCode": 13, - "versionName": "1.1.11", - "outputFile": "app-release.apk" - } - ], - "elementType": "File" -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ffd974c0..50bbb7fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -17,11 +18,15 @@ android:maxSdkVersion="32" /> - + + + + + + + + + + + + diff --git a/app/src/main/java/com/eatssu/android/App.kt b/app/src/main/java/com/eatssu/android/App.kt index f46558ab..af142e29 100644 --- a/app/src/main/java/com/eatssu/android/App.kt +++ b/app/src/main/java/com/eatssu/android/App.kt @@ -10,7 +10,7 @@ import timber.log.Timber @HiltAndroidApp class App: Application() { companion object{ - lateinit var appContext : Context + lateinit var appContext: Context //todo 이거 빼기 } override fun onCreate() { diff --git a/app/src/main/java/com/eatssu/android/data/repository/PreferencesRepository.kt b/app/src/main/java/com/eatssu/android/data/repository/PreferencesRepository.kt new file mode 100644 index 00000000..de5b771b --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/repository/PreferencesRepository.kt @@ -0,0 +1,31 @@ +// PreferencesRepository.kt +package com.eatssu.android.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class PreferencesRepository(private val context: Context) { + + private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + + companion object { + private val DAILY_NOTIFICATION_KEY = booleanPreferencesKey("daily_notification") + } + + val dailyNotificationStatus: Flow = context.dataStore.data + .map { preferences -> + preferences[DAILY_NOTIFICATION_KEY] ?: false // Default value is false + } + + suspend fun setDailyNotificationStatus(status: Boolean) { + context.dataStore.edit { preferences -> + preferences[DAILY_NOTIFICATION_KEY] = status + } + } +} diff --git a/app/src/main/java/com/eatssu/android/data/usecase/AlarmUsecase.kt b/app/src/main/java/com/eatssu/android/data/usecase/AlarmUsecase.kt new file mode 100644 index 00000000..94a65105 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/usecase/AlarmUsecase.kt @@ -0,0 +1,44 @@ +package com.eatssu.android.data.usecase + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.eatssu.android.util.NotificationReceiver +import java.util.Calendar +import javax.inject.Inject + +class AlarmUseCase @Inject constructor(private val context: Context) { + + fun scheduleAlarm() { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, NotificationReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 11) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + if (calendar.timeInMillis <= System.currentTimeMillis()) { + calendar.add(Calendar.DAY_OF_YEAR, 1) + } + + alarmManager.setRepeating( + AlarmManager.RTC_WAKEUP, calendar.timeInMillis, AlarmManager.INTERVAL_DAY, pendingIntent + ) + } + + fun cancelAlarm() { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, NotificationReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + alarmManager.cancel(pendingIntent) + } +} diff --git a/app/src/main/java/com/eatssu/android/data/usecase/GetDailyNotificationStatusUseCase.kt b/app/src/main/java/com/eatssu/android/data/usecase/GetDailyNotificationStatusUseCase.kt new file mode 100644 index 00000000..de4cd4e4 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/usecase/GetDailyNotificationStatusUseCase.kt @@ -0,0 +1,13 @@ +package com.eatssu.android.data.usecase + +import com.eatssu.android.data.repository.PreferencesRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetDailyNotificationStatusUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + operator fun invoke(): Flow { + return preferencesRepository.dailyNotificationStatus + } +} diff --git a/app/src/main/java/com/eatssu/android/data/usecase/SetDailyNotificationStatusUseCase.kt b/app/src/main/java/com/eatssu/android/data/usecase/SetDailyNotificationStatusUseCase.kt new file mode 100644 index 00000000..4eba7b51 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/usecase/SetDailyNotificationStatusUseCase.kt @@ -0,0 +1,13 @@ +package com.eatssu.android.data.usecase + +import com.eatssu.android.data.repository.PreferencesRepository +import javax.inject.Inject + + +class SetDailyNotificationStatusUseCase @Inject constructor( + private val preferencesRepository: PreferencesRepository +) { + suspend operator fun invoke(status: Boolean) { + preferencesRepository.setDailyNotificationStatus(status) + } +} diff --git a/app/src/main/java/com/eatssu/android/di/AppModule.kt b/app/src/main/java/com/eatssu/android/di/AppModule.kt new file mode 100644 index 00000000..5b4f9e17 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/di/AppModule.kt @@ -0,0 +1,28 @@ +package com.eatssu.android.di + +import android.app.Application +import android.content.Context +import com.eatssu.android.data.repository.PreferencesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application.applicationContext + } + + @Provides + @Singleton + fun providePreferencesRepository(@ApplicationContext context: Context): PreferencesRepository { + return PreferencesRepository(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/ui/login/LoginActivity.kt b/app/src/main/java/com/eatssu/android/ui/login/LoginActivity.kt index caf6e26a..82cd2393 100644 --- a/app/src/main/java/com/eatssu/android/ui/login/LoginActivity.kt +++ b/app/src/main/java/com/eatssu/android/ui/login/LoginActivity.kt @@ -5,7 +5,7 @@ import android.view.View import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import com.eatssu.android.base.BaseActivity -import com.eatssu.android.databinding.ActivitySocialLoginBinding +import com.eatssu.android.databinding.ActivityLoginBinding import com.eatssu.android.ui.main.MainActivity import com.eatssu.android.util.extension.showToast import com.eatssu.android.util.extension.startActivity @@ -20,7 +20,7 @@ import timber.log.Timber @AndroidEntryPoint class LoginActivity : - BaseActivity(ActivitySocialLoginBinding::inflate) { + BaseActivity(ActivityLoginBinding::inflate) { private val loginViewModel: LoginViewModel by viewModels() diff --git a/app/src/main/java/com/eatssu/android/ui/main/MainActivity.kt b/app/src/main/java/com/eatssu/android/ui/main/MainActivity.kt index 62066911..a7d1ab43 100644 --- a/app/src/main/java/com/eatssu/android/ui/main/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/ui/main/MainActivity.kt @@ -2,6 +2,7 @@ package com.eatssu.android.ui.main import android.annotation.SuppressLint import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.Menu @@ -10,6 +11,8 @@ import android.view.View import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -22,6 +25,7 @@ import com.eatssu.android.ui.main.calendar.CalendarAdapter import com.eatssu.android.ui.main.calendar.CalendarAdapter.OnItemListener import com.eatssu.android.ui.main.calendar.CalendarViewModel import com.eatssu.android.ui.mypage.MyPageActivity +import com.eatssu.android.ui.mypage.MyPageViewModel import com.eatssu.android.ui.mypage.usernamechange.UserNameChangeActivity import com.eatssu.android.util.CalendarUtils import com.eatssu.android.util.CalendarUtils.daysInWeekArray @@ -30,18 +34,20 @@ import com.eatssu.android.util.extension.showToast import com.eatssu.android.util.extension.startActivity import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.prolificinteractive.materialcalendarview.* import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber +import java.text.SimpleDateFormat import java.time.LocalDate -import java.util.* +import java.util.Locale @AndroidEntryPoint class MainActivity : BaseActivity(ActivityMainBinding::inflate), OnItemListener { private val mainViewModel: MainViewModel by viewModels() + private val myPageViewModel: MyPageViewModel by viewModels() + private lateinit var calendarViewModel: CalendarViewModel @@ -57,6 +63,24 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl setupNoToolbar() + // 알림 퍼미션 있는지 자가 진단 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + // 권한이 없다면 요청 + ActivityCompat.requestPermissions( + this, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 1000 + ) + } else { + // 권한이 이미 있어 + } + } + checkNicknameIsNull() // 1) ViewPager2 참조 @@ -192,4 +216,26 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl } } + // 권한 요청 결과 처리 + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + + if (requestCode == 1000) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // 권한이 승인됨 + showToast("EAT-SSU 알림 수신을 동의하였습니다.") + myPageViewModel.setNotificationOn() //바로 알림 받도록 설정 + } else { + // 권한이 거부됨 + showToast("EAT-SSU 알림 수신을 거부하였습니다.\n$dateFormat") + myPageViewModel.setNotificationOff() //바로 알림 받도록 설정 + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/ui/main/calendar/CalendarAdapter.kt b/app/src/main/java/com/eatssu/android/ui/main/calendar/CalendarAdapter.kt index 421ee62d..372ad2b6 100644 --- a/app/src/main/java/com/eatssu/android/ui/main/calendar/CalendarAdapter.kt +++ b/app/src/main/java/com/eatssu/android/ui/main/calendar/CalendarAdapter.kt @@ -1,7 +1,9 @@ package com.eatssu.android.ui.main.calendar +import android.os.Build import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.eatssu.android.R @@ -32,18 +34,36 @@ internal class CalendarAdapter( } + @RequiresApi(Build.VERSION_CODES.O) override fun onBindViewHolder(holder: CalendarViewHolder, position: Int) { val date = days[position] holder.dayOfMonth.text = date.dayOfMonth.toString() holder.dayText.text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN).toString() - if (date == CalendarUtils.selectedDate) { - //날짜 + /** + * iOS의 FSCalendar를 Custom으로 만들었습니다. + * 1. 선택한 날짜는 primary color로 select합니다. + * 2. 오늘 날짜 != 선택한 날짜 일 경우에는, 오늘 날짜의 text 색상을 primary color 표기하여, 오늘 날짜를 강조합니다. + */ + + if (date == CalendarUtils.selectedDate) { //셀렉트 된 날짜 holder.dayOfMonth.setBackgroundResource(R.drawable.selector_background_blue) - holder.dayOfMonth.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.selector_calendar_colortext)) - } - else { + holder.dayOfMonth.setTextColor( + ContextCompat.getColor( + holder.itemView.context, + R.color.selector_calendar_colortext + ) + ) + } else if (date == LocalDate.now() && date != CalendarUtils.selectedDate) { + //오늘 날짜가 선택 되지 않았을 때, 오늘 날 text 색 지정 + holder.dayOfMonth.setTextColor( + ContextCompat.getColor( + holder.itemView.context, + R.color.primary + ) + ) + } else { //다른 날짜들 holder.parentView.setBackgroundResource(R.drawable.ic_selector_background_white) } } diff --git a/app/src/main/java/com/eatssu/android/ui/mypage/MyPageActivity.kt b/app/src/main/java/com/eatssu/android/ui/mypage/MyPageActivity.kt index eaa816d9..4645b303 100644 --- a/app/src/main/java/com/eatssu/android/ui/mypage/MyPageActivity.kt +++ b/app/src/main/java/com/eatssu/android/ui/mypage/MyPageActivity.kt @@ -1,19 +1,26 @@ package com.eatssu.android.ui.mypage + +import android.app.Activity +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Paint import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.ViewModelProvider +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import com.eatssu.android.BuildConfig +import androidx.lifecycle.repeatOnLifecycle import com.eatssu.android.R import com.eatssu.android.base.BaseActivity -import com.eatssu.android.data.repository.FirebaseRemoteConfigRepository import com.eatssu.android.databinding.ActivityMyPageBinding -import com.eatssu.android.ui.common.VersionViewModel -import com.eatssu.android.ui.common.VersionViewModelFactory import com.eatssu.android.ui.login.LoginActivity import com.eatssu.android.ui.mypage.myreview.MyReviewListActivity import com.eatssu.android.ui.mypage.terms.WebViewActivity @@ -23,41 +30,56 @@ import com.eatssu.android.util.extension.startActivity import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import timber.log.Timber @AndroidEntryPoint class MyPageActivity : BaseActivity(ActivityMyPageBinding::inflate) { private val myPageViewModel: MyPageViewModel by viewModels() - private lateinit var versionViewModel: VersionViewModel - - private lateinit var firebaseRemoteConfigRepository: FirebaseRemoteConfigRepository - + @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) toolbarTitle.text = "마이페이지" // 툴바 제목 설정 - - initViewModel() + binding.tvSignout.paintFlags = Paint.UNDERLINE_TEXT_FLAG + setupObservers() setOnClickListener() - setData() - } - - override fun onResume() { - super.onResume() - setData() //Todo 최선일까? } - override fun onRestart() { - super.onRestart() + private fun setupObservers() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + myPageViewModel.uiState.collect { + binding.tvAppVersion.text = it.appVersion + + if (it.nickname.isNotEmpty()) { + binding.tvNickname.text = it.nickname + } - setData() + binding.alarmSwitch.isChecked = it.isAlarmOn + } + } + } } + @RequiresApi(Build.VERSION_CODES.O) private fun setOnClickListener() { + binding.alarmSwitch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + if (checkNotificationPermission(this)) { //허용되어 있는 상태 + myPageViewModel.setNotificationOn() + showToast("EAT-SSU 알림 수신을 동의하였습니다.") + } else { // 알림 권한이 없을 때 사용자에게 설정 화면으로 이동하라고 알림 + showNotificationPermissionDialog() + } + } else { + myPageViewModel.setNotificationOff() + showToast("EAT-SSU 알림 수신을 거부하였습니다.") + } + } + binding.llNickname.setOnClickListener { startActivity() } @@ -77,18 +99,17 @@ class MyPageActivity : BaseActivity(ActivityMyPageBinding showLogoutDialog() } - binding.tvSignout.setOnClickListener { + binding.llSignout.setOnClickListener { val intent = Intent(this, SignOutActivity::class.java) intent.putExtra("nickname", myPageViewModel.uiState.value.nickname) startActivity(intent) -// showSignoutDialog() } binding.llDeveloper.setOnClickListener { startActivity() } - binding.llStoreAppVersion.setOnClickListener { + binding.llAppVersion.setOnClickListener { moveToPlayStore() } @@ -108,34 +129,29 @@ class MyPageActivity : BaseActivity(ActivityMyPageBinding } - private fun setData() { - binding.tvAppVersion.text = BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")" - binding.tvStoreAppVersion.text = versionViewModel.checkVersionCode().toString() - - myPageViewModel.getMyInfo() - - lifecycleScope.launch { - Timber.d("관찰시작") - myPageViewModel.uiState.collectLatest { - if (it.nickname.isNotEmpty()) { - binding.tvNickname.text = it.nickname - } + @RequiresApi(Build.VERSION_CODES.O) + private fun showNotificationPermissionDialog() { + AlertDialog.Builder(this) + .setTitle("알림 권한 필요") + .setMessage("알림을 받으려면 알림 권한을 활성화해야 합니다. 설정 화면으로 이동하시겠습니까?") + .setPositiveButton("설정으로 이동") { _, _ -> + openAppNotificationSettings(this) } - } + .setNegativeButton("취소", null) + .show() } - private fun initViewModel() { //Todo 리팩토링하기 - - firebaseRemoteConfigRepository = FirebaseRemoteConfigRepository() - - versionViewModel = ViewModelProvider( - this, - VersionViewModelFactory(firebaseRemoteConfigRepository) - )[VersionViewModel::class.java] + private fun checkNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true // Android 13 이전 버전에서는 알림 권한이 필요하지 않음 + } } - - private fun showLogoutDialog() { // 다이얼로그를 생성하기 위해 Builder 클래스 생성자를 이용해 줍니다. @@ -213,4 +229,50 @@ class MyPageActivity : BaseActivity(ActivityMyPageBinding ) } } + + // 알림 권한 요청 함수 + private fun requestNotificationPermission(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + activity, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + REQUEST_NOTIFICATION_PERMISSION + ) + } + } + + + @RequiresApi(Build.VERSION_CODES.O) + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_NOTIFICATION_PERMISSION) { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + // 권한이 허용되었을 때 알림 설정 + myPageViewModel.setNotificationOn() + } else { + // 권한이 거부되었을 때 처리 + showToast("EAT-SSU 알림 수신을 거부하였습니다.") + openAppNotificationSettings(this) + myPageViewModel.setNotificationOff() + } + } + } + + + @RequiresApi(Build.VERSION_CODES.O) + private fun openAppNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + + + companion object { + private const val REQUEST_NOTIFICATION_PERMISSION = 1001 + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/ui/mypage/MyPageViewModel.kt b/app/src/main/java/com/eatssu/android/ui/mypage/MyPageViewModel.kt index b0c840fb..923213e3 100644 --- a/app/src/main/java/com/eatssu/android/ui/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/eatssu/android/ui/mypage/MyPageViewModel.kt @@ -3,9 +3,14 @@ package com.eatssu.android.ui.mypage import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.eatssu.android.BuildConfig +import com.eatssu.android.data.repository.PreferencesRepository +import com.eatssu.android.data.usecase.AlarmUseCase +import com.eatssu.android.data.usecase.GetDailyNotificationStatusUseCase import com.eatssu.android.data.usecase.GetUserInfoUseCase import com.eatssu.android.data.usecase.LogoutUseCase import com.eatssu.android.data.usecase.SetAccessTokenUseCase +import com.eatssu.android.data.usecase.SetDailyNotificationStatusUseCase import com.eatssu.android.data.usecase.SetRefreshTokenUseCase import com.eatssu.android.data.usecase.SignOutUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,16 +32,37 @@ class MyPageViewModel @Inject constructor( private val getUserInfoUseCase: GetUserInfoUseCase, private val setAccessTokenUseCase: SetAccessTokenUseCase, private val setRefreshTokenUseCase: SetRefreshTokenUseCase, + private val setNotificationStatusUseCase: SetDailyNotificationStatusUseCase, + private val getDailyNotificationStatusUseCase: GetDailyNotificationStatusUseCase, + private val alarmUseCase: AlarmUseCase, + private val preferencesRepository: PreferencesRepository // Assuming you're using DataStore here ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(MyPageState()) val uiState: StateFlow = _uiState.asStateFlow() init { + setAppVersion() getMyInfo() + getNotificationStatus() } - fun getMyInfo() { + private fun setAppVersion() { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy(appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + } + } + + private fun getNotificationStatus() { + viewModelScope.launch { + preferencesRepository.dailyNotificationStatus.collect { isAlarmOn -> + _uiState.value = _uiState.value.copy(isAlarmOn = isAlarmOn) + } + } + } + + private fun getMyInfo() { viewModelScope.launch { getUserInfoUseCase().onStart { _uiState.update { it.copy(loading = true) } @@ -87,7 +113,6 @@ class MyPageViewModel @Inject constructor( } } - fun signOut() { viewModelScope.launch { signOutUseCase().onStart { @@ -113,6 +138,22 @@ class MyPageViewModel @Inject constructor( } } + fun setNotificationOn() { + viewModelScope.launch { + setNotificationStatusUseCase(true) //로컬 디비 저장 + alarmUseCase.scheduleAlarm() //알람 매니저 + } + } + + fun setNotificationOff() { + viewModelScope.launch { + setNotificationStatusUseCase(false) + alarmUseCase.cancelAlarm() + } + } + + + companion object { val TAG = "MyPageViewModel" } @@ -127,6 +168,8 @@ data class MyPageState( var nickname: String = "", var platform: String = "", + var isAlarmOn: Boolean = false, + var appVersion: String = "0.0.0", var isNicknameNull: Boolean = false, var isLoginOuted: Boolean = false, diff --git a/app/src/main/java/com/eatssu/android/util/MySharedPreferences.kt b/app/src/main/java/com/eatssu/android/util/MySharedPreferences.kt index 30a3a4e0..241f0de5 100644 --- a/app/src/main/java/com/eatssu/android/util/MySharedPreferences.kt +++ b/app/src/main/java/com/eatssu/android/util/MySharedPreferences.kt @@ -69,7 +69,7 @@ object MySharedPreferences { context.getSharedPreferences(MY_ACCOUNT, Context.MODE_PRIVATE) val editor: SharedPreferences.Editor = prefs.edit() editor.putString("REFRESH_TOKEN", input) - editor.commit() + editor.apply() } fun getRefreshToken(context: Context): String { @@ -83,6 +83,21 @@ object MySharedPreferences { context.getSharedPreferences(MY_ACCOUNT, Context.MODE_PRIVATE) val editor: SharedPreferences.Editor = prefs.edit() editor.clear() - editor.commit() + editor.apply() + } + + fun setDailyNotification(context: Context, input: Boolean) { + val prefs: SharedPreferences = + context.getSharedPreferences(MY_ACCOUNT, Context.MODE_PRIVATE) + val editor: SharedPreferences.Editor = prefs.edit() + + editor.putBoolean("ALARM_ON", input) + editor.apply() + } + + fun getDailyNotification(context: Context): Boolean { + val prefs: SharedPreferences = + context.getSharedPreferences(MY_ACCOUNT, Context.MODE_PRIVATE) + return prefs.getBoolean("ALARM_ON", false) } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/util/NotificationReceiver.kt b/app/src/main/java/com/eatssu/android/util/NotificationReceiver.kt new file mode 100644 index 00000000..3d40135d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/util/NotificationReceiver.kt @@ -0,0 +1,68 @@ +package com.eatssu.android.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.eatssu.android.R +import com.eatssu.android.ui.main.MainActivity + +class NotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + showNotification(context) + } + + private fun showNotification(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "점심시간 전 알림", + NotificationManager.IMPORTANCE_HIGH // 중요도를 높게 설정 + ).apply { + description = "점심시간 전, 푸시알림을 발송합니다." + enableLights(true) + enableVibration(true) // 진동도 활성화 + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC // 잠금 화면에서도 표시 + } + notificationManager.createNotificationChannel(channel) + } + + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_alarm_logo) + .setContentTitle(context.getString(R.string.notification_context_title)) + .setContentText(context.getString(R.string.notification_context_text)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } + + companion object { + private const val CHANNEL_ID = "DailyNotificationChannel" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/res/drawable/ic_alarm_logo.xml b/app/src/main/res/drawable/ic_alarm_logo.xml new file mode 100644 index 00000000..9de0b64c --- /dev/null +++ b/app/src/main/res/drawable/ic_alarm_logo.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unsubscribe_16.png b/app/src/main/res/drawable/ic_unsubscribe_16.png new file mode 100644 index 00000000..dc3f0fad Binary files /dev/null and b/app/src/main/res/drawable/ic_unsubscribe_16.png differ diff --git a/app/src/main/res/drawable/img_kakao_login_btn.png b/app/src/main/res/drawable/img_kakao_login_btn.png new file mode 100644 index 00000000..932d4b0f Binary files /dev/null and b/app/src/main/res/drawable/img_kakao_login_btn.png differ diff --git a/app/src/main/res/drawable/img_kakao_login_large_wide.png b/app/src/main/res/drawable/img_kakao_login_large_wide.png deleted file mode 100644 index 77f6df4d..00000000 Binary files a/app/src/main/res/drawable/img_kakao_login_large_wide.png and /dev/null differ diff --git a/app/src/main/res/drawable/img_logo2.png b/app/src/main/res/drawable/img_logo2.png deleted file mode 100644 index eda4a6fa..00000000 Binary files a/app/src/main/res/drawable/img_logo2.png and /dev/null differ diff --git a/app/src/main/res/drawable/img_look.png b/app/src/main/res/drawable/img_look.png deleted file mode 100644 index 682b8701..00000000 Binary files a/app/src/main/res/drawable/img_look.png and /dev/null differ diff --git a/app/src/main/res/drawable/img_new_logo_primary.png b/app/src/main/res/drawable/img_new_logo_primary.png new file mode 100644 index 00000000..3e3f2ef8 Binary files /dev/null and b/app/src/main/res/drawable/img_new_logo_primary.png differ diff --git a/app/src/main/res/drawable/img_new_logo_white.png b/app/src/main/res/drawable/img_new_logo_white.png new file mode 100644 index 00000000..6cceac1d Binary files /dev/null and b/app/src/main/res/drawable/img_new_logo_white.png differ diff --git a/app/src/main/res/drawable/img_splash.png b/app/src/main/res/drawable/img_splash.png deleted file mode 100644 index 5b0aac6f..00000000 Binary files a/app/src/main/res/drawable/img_splash.png and /dev/null differ diff --git a/app/src/main/res/drawable/imgkakao_login_large.png b/app/src/main/res/drawable/imgkakao_login_large.png deleted file mode 100644 index 4e8c55c8..00000000 Binary files a/app/src/main/res/drawable/imgkakao_login_large.png and /dev/null differ diff --git a/app/src/main/res/drawable/selector_toggle.xml b/app/src/main/res/drawable/selector_toggle.xml new file mode 100644 index 00000000..7d1ff9df --- /dev/null +++ b/app/src/main/res/drawable/selector_toggle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_toggle.xml b/app/src/main/res/drawable/shape_toggle.xml new file mode 100644 index 00000000..46a1241c --- /dev/null +++ b/app/src/main/res/drawable/shape_toggle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_intro.xml b/app/src/main/res/layout/activity_intro.xml index b0acc86d..01130b7d 100644 --- a/app/src/main/res/layout/activity_intro.xml +++ b/app/src/main/res/layout/activity_intro.xml @@ -4,13 +4,15 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/primary" tools:context=".ui.login.IntroActivity"> - + app:layout_constraintTop_toBottomOf="@+id/iv_logo"> - + + + + + - - - + app:layout_constraintStart_toStartOf="parent" /> - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 24ae615b..a6d59059 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -24,7 +24,7 @@ android:layout_width="wrap_content" android:layout_height="28dp" android:background="@color/white" - android:src="@drawable/img_logo2" + android:src="@drawable/img_new_logo_primary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/activity_my_page.xml b/app/src/main/res/layout/activity_my_page.xml index 6c6ce5a4..aff22628 100644 --- a/app/src/main/res/layout/activity_my_page.xml +++ b/app/src/main/res/layout/activity_my_page.xml @@ -13,15 +13,13 @@ + android:orientation="vertical"> @@ -39,9 +37,9 @@ @@ -63,7 +61,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginTop="16dp"> + android:layout_marginTop="4dp"> + + + + + + + + + + + + + + + @@ -130,7 +169,7 @@ android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_arrow_right" - android:tint="@color/gray300" /> + app:tint="@color/gray300" /> @@ -165,8 +204,7 @@ android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_arrow_right" - android:tint="@color/gray300" /> - + app:tint="@color/gray300" /> @@ -201,7 +239,7 @@ android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_arrow_right" - android:tint="@color/gray300" /> + app:tint="@color/gray300" /> @@ -216,7 +254,6 @@ android:paddingEnd="24dp" android:paddingBottom="18dp"> - + app:tint="@color/gray300" /> - - - - - - - + app:tint="@color/gray300" /> - + + android:paddingBottom="18dp" + android:text="@string/logout" + android:textColor="@color/black" /> + + @@ -333,52 +356,41 @@ style="@style/Caption2" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="16dp" - android:textColor="@color/black" + android:textColor="@color/gray400" app:layout_constraintBottom_toBottomOf="@+id/tv_app_version_title" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@+id/tv_app_version_title" /> + - - - - + android:paddingBottom="10dp"> + android:background="?android:attr/selectableItemBackground" + android:gravity="end" + android:text="@string/signout" + android:textColor="@color/gray400" /> + + - - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 7eab13c7..e2b97086 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -6,7 +6,7 @@ @color/gray600 @color/white - @color/primary + @color/secondary @color/gray200 @color/black @@ -19,8 +19,9 @@ @color/gray200 - @color/primary - @color/primary + @color/secondary + @color/secondary + true @@ -53,96 +54,94 @@ - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0eda426..85a4d813 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,15 +28,13 @@ lifecycle-livedata = "2.7.0" kakao-login = "2.8.6" hilt = "2.50" play-services-base = "18.0.1" -firebase-config = "21.4.1" -firebase-bom = "32.2.2" +firebase-bom = "32.6.0" firebase-crashlytics = "2.9.9" timber = "5.0.1" google-services = "4.4.2" kotlin-android = "1.8.10" activity-version = "1.9.1" - [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } @@ -76,17 +74,18 @@ kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao- hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } - play_services_base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "play-services-base" } -firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx", version.ref = "firebase-config" } -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } + +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-config = { module = "com.google.firebase:firebase-config" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity-version" } + [plugins] android-application = { id = "com.android.application", version.ref = "android" } android-library = { id = "com.android.library", version.ref = "android" }