-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: 영화 티켓 예매 마이그레이션 * docs: 예매 내역 기능 구현 목록 작성 * feat(MainActivity): 바텀 내비게이션 추가 * feat(MainActivity): 바텀 네비게이션 클릭 시 해당 프래그먼트로 이동하는 기능 구현 * feat(HistoryFragment): 리사이클러뷰 예매 내역 아이템 xml 구현 * refactor(Reservation): 예약 정보를 Reservation 모델로 전달하도록 변경 * feat(HistoryFragment): 예매 내역 목록을 보여주는 기능 구현 * feat(HistoryFragment): 예매 내역 목록을 보여주는 기능 구현 * feat(MainActivity): 알림권한 허용을 요청하는 기능 구현 * feat(SettingFragment): 설정 화면 구현 * feat(Preferences): 설정 데이터를 저장하는 SharedPreference 구현 * feat(SettingFragment): 설정에서 알림 기능을 On/Off 하는 기능 구현 * feat(HistoryFragment): 예매 기록 구분선 추가 * feat: 영화 시작 시간 30분 전에 푸시 알림, 푸시 알림을 클릭하면 예매 정보를 보여주는 기능 구현 * refactor: AlarmReceiver 클래스명 변경 * refactor: Reminder를 추상화하도록 변경 - Reminder is related with notification * refactor(PushReceiver): 푸시 알림 수신 여부에 따라 알림 제어 * refactor(PushReceiver): 푸시 알림 수신 여부에 따라 알림 제어 * refactor(PushAlarmManager): AlarmManager 클래스 분리 * refactor(Notification): 아이콘 변경 * refactor(PushAlarmManager): set함수에서 시간을 Long타입으로 변환하도록 수정 * fix(ReservationPushReceiver): 알림이 오지 않던 문제 키 값 변경으로 해결 * refactor(MovieApplication): sharedPreferences를 application이 관리하도록 변경 * refactor(ContextExt): 권한확인 기능을 확장 함수로 분리 * feat: 권한이 허용되어 있지 않을 때 푸시 스위치를 켜면, 권한 요청 다이얼로그를 보여주는 기능 구현 * feat(SettingFragment): 권한이 없는 상태에서 푸시 스위치를 켜면 시스템 설정 화면으로 이동하는 기능 구현 * test(SettingFragment): 세팅 화면 테스트 코드 작성 * refactor: Fragment 이동하는 기능을 함수로 분리 * refactor: 사용자에게 권한 요청을 한 번만 하도록 변경 * fix(PushAlarmManager): PendingIntent 생성시 고유한 값을 사용하도록 변경 * refactor(PushAlarmManager): pendingIntent 생성을 run 스코프 함수로 초기화하도록 변경 * refactor: 예매 내역 아이템을 클릭했을 때 이벤트를 함수로 분리 * refactor: Fragment의 newInstance() 호출시 불필요한 함수 호출 제거 * refactor(AlarmManager): set() 함수 호출시 intent와 푸시할 데이터를 전달받도록 변경 * refactor(DataStore): Preference의 이름을 LocalDataStore로 변경하고 DataStore를 구현하도록 수정 * refactor(LocalDataStore): Application을 제거하고 싱글톤으로 생성하도록 변경 - Application 의 생명주기 작동하지 않는 경우가 있기 때문 * refactor(TicketingResultActivity): intent 생성 방식을 동반 객체에서 정의하도록 변경 * refactor: Reservation 도메인 생성 * fix: 푸시 알림을 30일 이전이 아닌 30분 전으로 수정 * fix(TicketingResultActivity): intent() 메서드에 companion object가 아니라 액티비티 타입을 전달하도록 변경
- Loading branch information
Showing
148 changed files
with
4,018 additions
and
245 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,88 @@ | ||
# android-movie-theater | ||
# android-movie-ticket | ||
|
||
## step1 | ||
|
||
### UI | ||
- [x] 영화를 리스트에 보여준다. | ||
- [x] 영화 예매 버튼을 누르면 상세 정보 화면으로 이동한다. | ||
- [x] 영화의 상세 정보를 보여준다. | ||
- [x] 예약할 인원을 선택할 수 있다. | ||
- [x] 예매 완료 버튼을 누르면 예약 완료 화면으로 이동한다. | ||
- [x] 뒤로가기 버튼을 누르면 영화 목록 화면으로 이동한다. | ||
- [x] 최종 예약 정보를 보여준다. | ||
- [x] 영화 결제 금액을 보여준다. | ||
- [x] 영화 정보를 보여준다. | ||
- [x] 뒤로가기 버튼을 누르면 영화 목록 화면으로 이동한다. | ||
|
||
### Domain | ||
- [x] 구매할 티켓에 대한 가격을 계산한다. | ||
|
||
## step2 | ||
|
||
### UI | ||
- [x] 영화 상영일 기간을 보여준다. | ||
- [x] 영화의 상영일은 각자의 범위를 갖는다. | ||
- [x] 영화 상세 정보 화면에서 날짜와 시간을 선택할 수 있다. | ||
- [x] 최종 예약 정보를 보여준다. | ||
- [x] 영화 상영 날짜와 시간을 보여준다. | ||
- [x] 할인된 영화 금액을 보여준다. | ||
- [x] 화면이 회전되어도 입력한 정보를 유지한다. | ||
- [x] 영화 예매 화면은 뒤로가기 버튼을 지원한다. | ||
|
||
### Domain | ||
- [x] 주말은 오전 9시, 평일은 오전 10시부터 자정까지 두 시간 간격으로 상영한다. | ||
- [x] 현재 시간 이전의 정보는 제외한다. | ||
- [x] 현재 날짜 이전의 정보는 제외한다. | ||
- [x] 시간 기본값은 현재 시간 바로 직후이다. | ||
- [x] 날짜 기본값은 금일이다. | ||
- [x] 할인 조건에 따라 적절한 할인 정책이 적용된다. | ||
- [x] 무비데이(매월 10, 20, 30일)일 때: 10% 할인 | ||
- [x] 조조(11시 이전)/야간(20시 이후)일 때: 2,000원 할인 | ||
- [x] 두 조건은 겹칠 수 있고 무비데이 할인이 선적용되어야 한다. | ||
- [x] 영화 티켓은 최소 1장, 최대 100장이다. | ||
|
||
## step3 | ||
|
||
### UI | ||
- [x] 좌석의 각 행은 알파벳, 열은 숫자로 표현한다. | ||
- [x] TableLayout을 사용한다. | ||
- [x] 좌석 수 변경에 대비하여 동적으로 좌석을 추가할 수 있다. | ||
- [x] 좌석을 선택하면 배경색이 바뀌고, 하단에 할인정책과 좌석 등급을 고려한 최종 가격이 표시된다. | ||
- [x] 선택된 좌석을 재선택하면 선택이 해제된다. | ||
- [x] 최종 예매를 확인하는 다이얼로그가 표시되고 배경을 터치해도 사라지지 않아야 한다. | ||
- [x] 좌석 등급에 따라 글자색이 다르다. | ||
- [x] 1, 2행은 보라색 글자이다. | ||
- [x] 3, 4행은 초록색 글자이다. | ||
- [x] 5행은 초록색 글자이다. | ||
- [x] 티켓 수까지만 선택할 수 있고, 그 이전에는 확인 버튼이 비활성화 상태이다. | ||
|
||
### Domain | ||
- [x] 좌석은 총 5행 4열로 구성되어 있다. | ||
- [x] 1행 보다 작으면 예외가 발생한다. | ||
- [x] 1열 보다 작으면 예외가 발생한다. | ||
- [x] 등급 정책은 다음과 같다. | ||
- [x] 1, 2행은 B등급이다. (10,000원) | ||
- [x] 3, 4행은 S등급이다. (15,000원) | ||
- [x] 5행은 A등급이다. (12,000원) | ||
|
||
### Programming | ||
- [x] 기능 요구 사항에 대한 UI 테스트를 작성해야 한다. | ||
|
||
# android-movie-theater | ||
|
||
### UI | ||
- [x] 네비게이션으로 영화 예매 앱의 기능을 사용할 수 있다. | ||
- [x] 예매 내역 : 예매한 영화 리스트 | ||
- [x] 홈 : 영화 리스트 | ||
- [x] 설정 : 빈 화면 | ||
- [x] 예매 내역(예매한 영화 리스트) 상세 | ||
- [x] 예매 내역을 터치하면 예매 정보를 보여준다. | ||
- [x] 리스트 항목을 누르는 효과를 줘야 한다. (시안 참고) | ||
- [x] 알림권한 허용을 요청한다. | ||
- [x] 설정에서 알림 기능을 On/Off 할 수 있다. | ||
- [x] 영화 시작 시간 30분 전에 푸시 알림이 온다. | ||
- [x] 푸시 알림을 클릭하면 예매 정보를 보여준다. | ||
|
||
### Data | ||
- [x] 사용자가 앱을 재실행해도 설정 데이터가 남아있어야 한다. | ||
- [x] SharedPreference를 사용한다. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
app/src/androidTest/java/woowacourse/movie/presentation/activities/custom/ClickItemWithId.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package woowacourse.movie.presentation.activities.custom | ||
|
||
import android.view.View | ||
import androidx.test.espresso.UiController | ||
import androidx.test.espresso.ViewAction | ||
import org.hamcrest.Matcher | ||
|
||
object ClickViewAction { | ||
fun clickViewWithId(id: Int): ViewAction = object : ViewAction { | ||
override fun getDescription(): String = "id를 기반으로 아이템을 클릭한다." | ||
|
||
override fun getConstraints(): Matcher<View>? = null | ||
|
||
override fun perform(uiController: UiController, view: View) { | ||
val v = view.findViewById<View>(id) | ||
v.performClick() | ||
} | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
...t/java/woowacourse/movie/presentation/activities/custom/RecyclerViewItemCountAssertion.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package woowacourse.movie.presentation.activities.custom | ||
|
||
import androidx.recyclerview.widget.RecyclerView | ||
import androidx.test.espresso.ViewAssertion | ||
import junit.framework.TestCase.assertEquals | ||
import junit.framework.TestCase.assertTrue | ||
|
||
object RecyclerViewAssertion { | ||
fun matchItemCount(expected: Int): ViewAssertion = ViewAssertion { view, noViewFoundException -> | ||
if (noViewFoundException != null) throw noViewFoundException | ||
|
||
assertTrue(view is RecyclerView) | ||
|
||
val recyclerAdapter = (view as RecyclerView).adapter | ||
val actual = recyclerAdapter?.itemCount | ||
assertEquals(expected, actual) | ||
} | ||
} |
64 changes: 64 additions & 0 deletions
64
...a/woowacourse/movie/presentation/activities/main/fragments/setting/SettingFragmentTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package woowacourse.movie.presentation.activities.main.fragments.setting | ||
|
||
import androidx.test.espresso.Espresso.onView | ||
import androidx.test.espresso.action.ViewActions.click | ||
import androidx.test.espresso.assertion.ViewAssertions.matches | ||
import androidx.test.espresso.matcher.ViewMatchers.isChecked | ||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed | ||
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked | ||
import androidx.test.espresso.matcher.ViewMatchers.withId | ||
import androidx.test.ext.junit.rules.ActivityScenarioRule | ||
import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
import org.junit.Rule | ||
import org.junit.Test | ||
import org.junit.runner.RunWith | ||
import woowacourse.movie.R | ||
import woowacourse.movie.presentation.activities.main.MainActivity | ||
import woowacourse.movie.presentation.activities.main.fragments.setting.SettingFragment.Companion.PUSH_ALLOW_KEY | ||
|
||
@RunWith(AndroidJUnit4::class) | ||
class SettingFragmentTest { | ||
|
||
@get:Rule | ||
internal val activityScenario = ActivityScenarioRule(MainActivity::class.java) | ||
|
||
@Test | ||
internal fun 설정_화면_클릭시_화면을_전환한다() { | ||
onView(withId(R.id.setting)) | ||
.check(matches(isDisplayed())) | ||
.perform(click()) | ||
|
||
onView(withId(R.id.notification_push_switch)) | ||
.check(matches(isDisplayed())) | ||
} | ||
|
||
@Test | ||
internal fun 푸시_동의_스위치가_활성화_상태일_때_누르면_비활성화한다() { | ||
setPushState(true) | ||
|
||
onView(withId(R.id.setting)) | ||
.check(matches(isDisplayed())) | ||
.perform(click()) | ||
|
||
onView(withId(R.id.notification_push_switch)) | ||
.perform(click()) | ||
.check(matches(isNotChecked())) | ||
} | ||
|
||
@Test | ||
internal fun 푸시_동의_스위치가_비활성화_상태일_때_누르면_활성화한다() { | ||
setPushState(false) | ||
|
||
onView(withId(R.id.setting)) | ||
.check(matches(isDisplayed())) | ||
.perform(click()) | ||
|
||
onView(withId(R.id.notification_push_switch)) | ||
.perform(click()) | ||
.check(matches(isChecked())) | ||
} | ||
|
||
private fun setPushState(value: Boolean) { | ||
MovieApplication.dataStore.setBoolean(PUSH_ALLOW_KEY, value) | ||
} | ||
} |
152 changes: 152 additions & 0 deletions
152
.../androidTest/java/woowacourse/movie/presentation/activities/movielist/HomeFragmentTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
package woowacourse.movie.presentation.activities.movielist | ||
|
||
import android.content.Intent | ||
import androidx.recyclerview.widget.RecyclerView | ||
import androidx.test.espresso.Espresso.onView | ||
import androidx.test.espresso.assertion.ViewAssertions.matches | ||
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnHolderItem | ||
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition | ||
import androidx.test.espresso.intent.Intents | ||
import androidx.test.espresso.intent.Intents.intended | ||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction | ||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent | ||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed | ||
import androidx.test.espresso.matcher.ViewMatchers.withId | ||
import androidx.test.ext.junit.rules.ActivityScenarioRule | ||
import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
import androidx.test.filters.LargeTest | ||
import junit.framework.TestCase.assertEquals | ||
import org.hamcrest.Matchers.instanceOf | ||
import org.hamcrest.Matchers.`is` | ||
import org.junit.After | ||
import org.junit.Before | ||
import org.junit.Rule | ||
import org.junit.Test | ||
import org.junit.runner.RunWith | ||
import woowacourse.movie.R | ||
import woowacourse.movie.presentation.activities.custom.ClickViewAction.clickViewWithId | ||
import woowacourse.movie.presentation.activities.custom.RecyclerViewAssertion.matchItemCount | ||
import woowacourse.movie.presentation.activities.main.fragments.home.HomeFragment | ||
import woowacourse.movie.presentation.activities.main.fragments.home.MovieListAdapter | ||
import woowacourse.movie.presentation.activities.main.fragments.home.type.MovieViewType | ||
import woowacourse.movie.presentation.activities.main.fragments.home.viewholder.NativeAdViewHolder | ||
import woowacourse.movie.presentation.activities.ticketing.TicketingActivity | ||
import woowacourse.movie.presentation.model.movieitem.Ad | ||
import woowacourse.movie.presentation.model.movieitem.Movie | ||
import java.time.LocalDate | ||
|
||
@RunWith(AndroidJUnit4::class) | ||
@LargeTest | ||
class HomeFragmentTest { | ||
private val adInterval = 3 | ||
|
||
@get:Rule | ||
internal val activityRule = ActivityScenarioRule(HomeFragment::class.java) | ||
|
||
@Before | ||
fun setup() { | ||
Intents.init() | ||
} | ||
|
||
@After | ||
fun tearDown() { | ||
Intents.release() | ||
} | ||
|
||
/** | ||
* [Movie] is fake constructor, not real constructor | ||
*/ | ||
private fun Movie(movieTitle: String, runningTime: Int): Movie = | ||
Movie( | ||
movieTitle, | ||
LocalDate.now(), LocalDate.now(), | ||
runningTime, "", | ||
R.drawable.img_sample_movie_thumbnail1 | ||
) | ||
|
||
private fun setCustomAdapter(movieSize: Int) { | ||
activityRule.scenario.onActivity { activity -> | ||
val movieRecyclerView = activity.findViewById<RecyclerView>(R.id.movies_rv) | ||
val adapter = MovieListAdapter(adInterval, Ad.provideDummy()) | ||
|
||
adapter.appendAll(List(movieSize) { Movie("title", 120) }) | ||
movieRecyclerView.adapter = adapter | ||
} | ||
} | ||
|
||
@Test | ||
internal fun 리사이클러뷰는_영화_정보_3개당_광고_1개를_나타낸다() { | ||
val movieSize = 20 | ||
val adSize = 6 | ||
val expected = movieSize + adSize | ||
|
||
setCustomAdapter(movieSize) | ||
|
||
onView(withId(R.id.movies_rv)) | ||
.check(matchItemCount(expected)) | ||
} | ||
|
||
@Test | ||
internal fun 세_번에_한_번씩_광고가_등장한다() { | ||
activityRule.scenario.onActivity { activity -> | ||
val recyclerView = activity.findViewById<RecyclerView>(R.id.movies_rv) | ||
|
||
val expected = recyclerView.adapter?.getItemViewType(adInterval) | ||
val actual = MovieViewType.AD.type | ||
|
||
assertEquals(expected, actual) | ||
} | ||
} | ||
|
||
@Test | ||
internal fun 세_번에_한_번_외에는_영화_정보가_등장한다() { | ||
activityRule.scenario.onActivity { activity -> | ||
val recyclerView = activity.findViewById<RecyclerView>(R.id.movies_rv) | ||
|
||
for (position in 0 until adInterval) { | ||
val expected = recyclerView.adapter?.getItemViewType(position) | ||
val actual = MovieViewType.MOVIE.type | ||
|
||
assertEquals(expected, actual) | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
internal fun 영화를_클릭하면_티켓팅_화면으로_이동한다() { | ||
// given | ||
onView(withId(R.id.movies_rv)) | ||
.check(matches(isDisplayed())) | ||
|
||
// when | ||
onView(withId(R.id.movies_rv)) | ||
.perform( | ||
actionOnItemAtPosition<RecyclerView.ViewHolder>( | ||
0, | ||
clickViewWithId(R.id.book_btn) | ||
) | ||
) | ||
|
||
// then | ||
intended(hasComponent(TicketingActivity::class.java.name)) | ||
} | ||
|
||
@Test | ||
internal fun `광고를_클릭하면_웹사이트가_열린다`() { | ||
// given | ||
onView(withId(R.id.movies_rv)) | ||
.check(matches(isDisplayed())) | ||
|
||
// when | ||
onView(withId(R.id.movies_rv)) | ||
.perform( | ||
actionOnHolderItem( | ||
`is`(instanceOf(NativeAdViewHolder::class.java)), | ||
clickViewWithId(R.id.native_ad_iv) | ||
).atPosition(0) | ||
) | ||
|
||
// then | ||
intended(hasAction(Intent.ACTION_VIEW)) | ||
} | ||
} |
Oops, something went wrong.