-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] Glance로 구현한 widget #251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media? 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR implements a home screen widget feature that allows users to view meal information from different restaurants directly on their home screen. The widget is built using Jetpack Compose Glance and includes comprehensive backend support for data management and updates.
- Widget implementation with restaurant selection and automatic meal time detection
- Background data synchronization using WorkManager with 30-minute intervals
- DataStore-based configuration management for widget-specific settings
Reviewed Changes
Copilot reviewed 48 out of 62 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| settings.gradle | Added core:design-system module dependency |
| gradle/libs.versions.toml | Updated dependency versions and added widget-related libraries (Glance, WorkManager, DataStore) |
| core/design-system/* | New design system module with reusable UI components for widget configuration |
| app/src/main/res/xml/* | Widget configuration files defining widget properties and behavior |
| app/src/main/java/.../widget/* | Complete widget implementation including UI, data management, and background workers |
| app/src/main/java/.../domain/* | New domain models and use cases for widget meal data handling |
| app/src/main/java/.../data/* | Enhanced repository and service layers to support widget data requirements |
| app/src/main/AndroidManifest.xml | Widget receiver registration and deep link configuration |
| app/build.gradle.kts | Updated dependencies and build configuration for widget support |
Comments suppressed due to low confidence (2)
app/src/main/java/com/eatssu/android/presentation/widget/ui/MealWidget.kt:1
- Commented-out code should be removed. If the padding is not needed, delete the comment entirely.
package com.eatssu.android.presentation.widget.ui
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
|
|
||
| fun fromRestaurantEnumName(enumName: String): String { |
Copilot
AI
Aug 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent indentation on line 19-20. The function declaration should align with the other companion object functions.
| fun fromRestaurantEnumName(enumName: String): String { | |
| fun fromRestaurantEnumName(enumName: String): String { |
app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingActivity.kt
Show resolved
Hide resolved
| override suspend fun getTodayMeal( //todo 분기처리 어떻게 할지? | ||
| date: String, | ||
| restaurant: String, | ||
| time: String | ||
| ): Flow<ArrayList<GetMealResponse>> { |
Copilot
AI
Aug 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO comment indicates incomplete implementation. The branching logic should be implemented or the TODO should be moved to a proper issue tracker.
| // 실패한 경우에는 Result.failure()로 실패 정보 반환 | ||
| // emit(response.message)) | ||
| } | ||
| } catch (e: Exception) { | ||
| // 네트워크 오류 또는 예외가 발생한 경우에는 Result.failure()로 반환 | ||
| // emit(ApiResult.Failure(e)) |
Copilot
AI
Aug 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented-out error handling code leaves the failure cases unhandled. Either implement proper error handling or throw appropriate exceptions.
| // 실패한 경우에는 Result.failure()로 실패 정보 반환 | |
| // emit(response.message)) | |
| } | |
| } catch (e: Exception) { | |
| // 네트워크 오류 또는 예외가 발생한 경우에는 Result.failure()로 반환 | |
| // emit(ApiResult.Failure(e)) | |
| // 실패한 경우에는 예외를 던져서 Flow에서 에러로 처리 | |
| throw Exception(response.message ?: "Unknown error") | |
| } | |
| } catch (e: Exception) { | |
| // 네트워크 오류 또는 예외가 발생한 경우에는 예외를 던져서 Flow에서 에러로 처리 | |
| throw e |
| var shrinkResources = false | ||
| var minifyEnabled = false |
Copilot
AI
Aug 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These variables are declared but never used. They should either be assigned to the build configuration properties or removed.
| var shrinkResources = false | |
| var minifyEnabled = false |
| implementation(libs.androidx.compose.ui.graphics) | ||
| implementation(libs.androidx.compose.ui.tooling.preview) | ||
| implementation(libs.androidx.compose.material3) | ||
| // androidTestImplementation(libs.androidx.compose.ui.test.junit4) |
Copilot
AI
Aug 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented-out dependency should either be removed or uncommented if needed for testing.
| // androidTestImplementation(libs.androidx.compose.ui.test.junit4) | |
| androidTestImplementation(libs.androidx.compose.ui.test.junit4) |
|
전반적으로 식당 이름 표기나 로직 상 구분할 때 String(Restaurant.displayName 값)이 많이 사용되는 것 같아요. |
app/src/main/java/com/eatssu/android/presentation/widget/MealWorker.kt
Outdated
Show resolved
Hide resolved
| } | ||
| } | ||
| // fallback to legacy key | ||
| val legacyRaw = prefs[legacyRestaurantKey(appWidgetId)] ?: "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
prefs에 값이 존재하지 않을 경우에 빈 문자열로 Restaurant.fromDisplayName를 호출하는데, 이 경우 무조건 Unknown display name 오류가 뜨게 되어있습니다!
실제로 로그를 봐도 Failed to cleanup DataStore for widget 4: Unknown display name: 이렇게 오류가 뜨더라구요.
loadRestaurantPref가 Restaurant?를 반환하게 해서 cleanupWidgetDataStore에서 호출할 때 null이면 파일 삭제 시도 안하게하면 될 것 같습니다!
|
|
||
| override fun getLocation(context: Context, fileKey: String): File { | ||
| // fileKey를 그대로 사용하여 파일명 생성 (appWidget-25 -> MealInfo_appWidget-25) | ||
| val filename = "${DATA_STORE_FILENAME_PREFIX}${fileKey}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 부분도 const로 되어있지만 파일명을 한 곳에서 모두 처리할 수 있게 로직을 분리하면 좋을 것 같아요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오... 이거 무슨 말인지 잘 모르겠어요.. 추가 코멘트 가능할까요?
| companion object { | ||
| private val gson = Gson() | ||
|
|
||
| data class WidgetSettings( | ||
| val restaurant: String = "", | ||
| val appWidgetLayout: String? = null, | ||
| ) | ||
|
|
||
| private fun settingsKey(appWidgetId: Int) = | ||
| stringPreferencesKey("widget_settings_$appWidgetId") | ||
|
|
||
| private fun legacyRestaurantKey(appWidgetId: Int) = | ||
| stringPreferencesKey("widget_restaurant_$appWidgetId") | ||
|
|
||
| private fun fileKeyRestaurantKey(fileKey: String) = | ||
| stringPreferencesKey("widget_restaurant_by_fileKey_$fileKey") | ||
|
|
||
| suspend fun saveRestaurantByFileKey( | ||
| context: Context, | ||
| fileKey: String, | ||
| restaurant: String, | ||
| ) { | ||
| context.dataStore.edit { prefs -> | ||
| prefs[fileKeyRestaurantKey(fileKey)] = restaurant | ||
| } | ||
| Timber.d("saveRestaurantByFileKey 호출됨: fileKey='$fileKey', restaurant='$restaurant'") | ||
| } | ||
|
|
||
| suspend fun loadRestaurantByFileKey(context: Context, fileKey: String): Restaurant? { | ||
| val prefs: Preferences = context.dataStore.data.first() | ||
| val value = prefs[fileKeyRestaurantKey(fileKey)] | ||
| Timber.d("loadRestaurantByFileKey 호출됨: fileKey='$fileKey', value='$value'") | ||
| if (value.isNullOrBlank()) { | ||
| return null | ||
| } | ||
| return runCatching { Restaurant.valueOf(value) }.getOrNull() | ||
| } | ||
|
|
||
|
|
||
| suspend fun loadRestaurantPref(context: Context, appWidgetId: Int): Restaurant { | ||
| val prefs: Preferences = context.dataStore.data.first() | ||
| val json = prefs[settingsKey(appWidgetId)] | ||
| if (!json.isNullOrBlank()) { | ||
| val settings = | ||
| runCatching { gson.fromJson(json, WidgetSettings::class.java) }.getOrNull() | ||
| if (settings != null && settings.restaurant.isNotBlank()) { | ||
| val enumName = Restaurant.fromDisplayName(settings.restaurant) | ||
| Timber.d("load restaurant from settings $enumName") | ||
| return Restaurant.valueOf(enumName) | ||
| } | ||
| } | ||
| // fallback to legacy key | ||
| val legacyRaw = prefs[legacyRestaurantKey(appWidgetId)] ?: "" | ||
| val legacyEnumName = Restaurant.fromDisplayName(legacyRaw) | ||
| return if (legacyEnumName.isNotBlank()) { | ||
| Timber.d("load restaurant (legacy) $legacyEnumName from '$legacyRaw' for appWidgetId $appWidgetId") | ||
| Restaurant.valueOf(legacyEnumName) | ||
| } else { | ||
| Timber.w("No saved restaurant for appWidgetId=$appWidgetId, defaulting to HAKSIK") | ||
| Restaurant.HAKSIK | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WidgetSettingActivity.kt 파일에서 companion object로 DataStore에 접근하는 코드를 public으로 제공하는 것은 좋지 않은 것 같아요.
PreferencesRepository처럼 WidgetPreferencesRepository를 만들어서 데이터 저장, 불러오기 코드를 모두 그쪽에 넣고 구현하는 것이 좋다고 생각합니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
data 레이어로의 분리는 생각하지 못했어요..!!! 분리해보도록 하겠습니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
85066d6 반영하였습니다!
app/src/main/java/com/eatssu/android/presentation/widget/MealWidgetReceiver.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/com/eatssu/android/presentation/map/MapFragmentComposeView.kt
Show resolved
Hide resolved
kangyuri1114
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
수고하셨어요!! P1 이 두 부분입니다
이건 배포 전에 꼭 확인해주셨으면 좋겠어요
(아시다시피 P3 -> P1로 갈 수록 중요한 것!)
P2 이하는 위젯 기능에 영향이 가지 않는다고 판단하시면 배포 후 수정하셔도 될 것 같습니다!
app/src/main/java/com/eatssu/android/data/dto/response/TokenValidationResponse.kt
Outdated
Show resolved
Hide resolved
| SNACK_CORNER("스낵 코너", MenuType.FIXED), | ||
| THE_KITCHEN("더 키친", MenuType.FIXED); | ||
|
|
||
| companion object { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Etc ]
data모듈에서 UI <> 서버 <> 비즈니스 로직 간 매핑 함수들이 생겨난거군요 좋네욤
| private val mealService: MealService, | ||
| ) : MealRepository { | ||
|
|
||
| override suspend fun getTodayMeal( //todo 분기처리 어떻게 할지? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion P2 ]
지금 실패 처리가 애매한거군요
UI단에는 성공 데이터만 방출하니..
커스텀 APIresult를 만들어 감싸서 모든 api 실패 시의 처리를 통일하면 좋겠지만 지금은 불가능하니
차라리 실패시에는 emptyList를 emit하는 건 어떨까요?
UI에서는 emptyList로 받는 경우의 예외처리(분기처리)를 진행하구요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
지금 레포지토리에서 dto 자체를 반환하고 있어서 emptyList 반환이 어렵습니다. 일단 List가 아니라 ArrayList이고, 레거시 자체를 고쳐야해서 다음 PR로 넘기는게 조을 것 같슴다..
리뷰 v2 작업하면서 리뷰 부분은 dto 자체를 넘기는 문제나, 불필요한 flow 등 레거시 제거가 완료되어 설계 자체에 큰 시간을 쓰지는 않을 것 같습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
e445acc 반영하였습니다!
공통 APIresult는 차차해보겠슴다
| ): Call<BaseResponse<ArrayList<GetMealResponse>>> | ||
|
|
||
| // todo 위에 함수를 call 없애서 하나로 합치길 바람 ㅜㅜ 위젯 때문에 급하게 복사본을 만듦 | ||
| @GET("meals") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Suggestion - P1 ]
알고 계시듯 기존 함수에서 Call 빼고 하나의 함수 사용으로 수정해야 겠네요!
이건 개인적으로 따로 기록해두시고 refact 브랜치에서 진행하죠!! 시간이 없으니
| android:minHeight="110dp" | ||
| android:minResizeWidth="180dp" | ||
| android:minResizeHeight="110dp" | ||
| android:previewImage="@drawable/img_widget_preview" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Question ]
이 이미지는 왜 필요한가요,,??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
기기에서 홈화면에 위젯 추가하는 화면에서 프리뷰 이미지가 필요합니당
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
작동 영상 보시면 위젯 프리뷰 이미지 있자나용 그거에여
| private const val DATA_STORE_FILENAME_PREFIX = "MealInfo_" | ||
|
|
||
| private val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) | ||
| private val storeByPath = ConcurrentHashMap<String, DataStore<WidgetMealInfo>>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Question ]
신기하네요.. 같은 파일에 대한 캐싱처리군용
근데 왜 ConcurrentHashMap를 활용한 캐싱이 필요한가요??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
구글링 해봤을 때는 "DataStore가 제공하는 내부 캐싱 메커니즘"이 있다고 해서요
| try { | ||
| runBlocking { | ||
| val restaurant = WidgetSettingActivity.loadRestaurantPref(context, appWidgetId) | ||
| val filename = "MealInfo_${restaurant.name}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Suggestion - P1 ]
MealInfoStateDefinition 파일 > getLocation 함수
에서 fileKey : Glance 프레임워크가 넘겨주는 위젯 인스턴스 구분자
-> 위젯 ID 단위로 DataStore 파일이 하나씩 생성됨
근데 MealWidgetReceiver 파일에서는 restaurant.name을 파일명 키로 사용 중
삭제할 때도 restaurant.name으로 찾아서 삭제하는 게 아닌 appWidgetId을 사용해 찾아서 삭제하는 게 맞을 거 같아요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
맞습니다!!!!
이전 방법으로 식당별로 datastore를 할당해서 저장하게 했다가 지금 방식으로 바꿔서 이렇게 남아있는 것 같아요!!!
코드 자세하게 봐주셔서 감사합니다🙇♀️ 바로 반영했어욤
| Timber.d("LaunchedEffect: 저장된 식당 정보 없음") | ||
| } | ||
|
|
||
| MealWorker.enqueue(context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Question ]
위젯 추가할 때마다 Worker가 중복 등록될 거 같은데 의도 하신걸까요??
모든 밀 위젯에 대해서는 1개의 워커만 있어도 되는 거 아닌지 그냥 궁금해요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
각각의 워커를 가지고 있는게 맞습니다!
하나의 워커는 하나의 식당의 식단만 불러오고 저장하고 UI한테 가져다주는 역할을 합니다.
사용자가 추가하지 않은 식당에 대해서 식단을 로컬에 저장할 필요가 없고, 또 일반적인 선에서는 한 식당의 위젯을 하나만 만들기에 각각 만드는 것이 리소스가 문제되지 않을 것이라 생각하여 그리 했습니다.
혹시 납득이 되셨나옹?
| val glanceId: GlanceId? = | ||
| if (appWidgetId != null && appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { | ||
| runBlocking { | ||
| GlanceAppWidgetManager(this@WidgetSettingActivity).getGlanceIdBy( | ||
| appWidgetId | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ Suggestion - P1 ]
setContent는 UI 스레드로 알고 있는데 runblocking 을 사용하면 ANR이 난다고 합니다
suspend 호출은 LaunchedEffect나 lifecycleScope.launch로 비동기 처리하는 게 안전하다고 하네요
| val glanceId: GlanceId? = | |
| if (appWidgetId != null && appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { | |
| runBlocking { | |
| GlanceAppWidgetManager(this@WidgetSettingActivity).getGlanceIdBy( | |
| appWidgetId | |
| ) | |
| } | |
| var glanceId by remember { mutableStateOf<GlanceId?>(null) } | |
| LaunchedEffect(appWidgetId) { | |
| if (appWidgetId != null && appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { | |
| glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) | |
| } | |
| } | |
kangyuri1114
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯
Summary
홈 화면 위젯을 구현했습니다. 사용자는 위젯을 통해 학생 식당, 도담 식당, 기숙사 식당의 식단 정보를 홈 화면에서 바로 확인할 수 있습니다.
Describe your changes
주요 기능
1. 위젯 설정 시스템
2. 시간별 메뉴 표시
3. 사용자 경험
시스템 아키텍처
graph TD A[위젯 설정 화면] --> B[식당 선택] B --> C[DataStore 저장] C --> D[위젯별 고유 키] E[MealInfoWidget] --> F[provideGlance] F --> G[식당 정보 로드] G --> H[MealWorker 실행] H --> I[API 호출] I --> J[메뉴 데이터 처리] J --> K[위젯 상태 업데이트] K --> L[UI 렌더링] M[시간별 메뉴] --> N[아침/점심/저녁] N --> O[현재 시간 감지] O --> P[해당 메뉴 표시]핵심 컴포넌트
1. 위젯 UI (
MealInfoWidget.kt)2. 상태 관리 (
MealInfoStateDefinition.kt)3. 데이터 처리 (
WidgetDataDisplayManager.kt)4. 백그라운드 작업 (
MealWorker.kt)5. 설정 관리 (
WidgetSettingActivity.kt)데이터 모델
WidgetMealInfo
데이터 플로우
1. 위젯 초기화
2. 메뉴 업데이트
3. 시간별 표시
Screen_Recording_20250831_141253_One.UI.Home.mp4
Issue
To reviewers