Skip to content

Commit

Permalink
[부나] 1, 2단계 영화 극장 선택 제출합니다. (#15)
Browse files Browse the repository at this point in the history
* 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
tmdgh1592 authored Apr 29, 2023
1 parent 443347f commit e2c45bf
Show file tree
Hide file tree
Showing 148 changed files with 4,018 additions and 245 deletions.
89 changes: 88 additions & 1 deletion README.md
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를 사용한다.
16 changes: 16 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
}

android {
namespace = "woowacourse.movie"
compileSdk = 33

testOptions {
animationsDisabled = true
}

defaultConfig {
applicationId = "woowacourse.movie"
minSdk = 26
Expand Down Expand Up @@ -36,11 +41,22 @@ android {
}

dependencies {
val fragmentVersion = "1.5.5"

implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
implementation(project(":domain"))
implementation(project(":data"))
implementation("androidx.fragment:fragment-ktx:1.4.0")
androidTestImplementation("androidx.fragment:fragment-testing:$fragmentVersion")
}
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()
}
}
}
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)
}
}
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)
}
}
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))
}
}
Loading

0 comments on commit e2c45bf

Please sign in to comment.