diff --git a/README.md b/README.md index 72be70cf1..56f19585a 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# android-movie-theater \ No newline at end of file +# android-movie-theater + +## domain + +### Movie +- [x] 제목, 상영기간, 러닝타임, 이미지를 알아야 한다. +- [x] MovieDetail을 소유해야 한다. + +### MovieDetail +- [x] 영화의 줄거리를 알아야 한다. + +### Reservation +- [x] 영화, 예약된 좌석, 선택한 날짜와 시간, 예매 금액을 알아야 한다. +- [x] 최소, 최대 예매 인원 수를 알아야 한다. + +### ReservationAgency +- [x] 영화, 인원 수, 선택된 날짜와 시간을 알아야 한다. +- [x] 예매 가능한지 판단한다. +- [x] 예매한다. (Reservation을 생성한다.) +- [x] 할인 정책이 적용된 총 금액을 계산한다. + +### DiscountPolicy +- [x] 할인 조건을 가져야 한다. +- [x] 할인 조건을 만족한다면 특정 금액의 할인 가격을 반환할 수 있다. + +### MovieDayDiscountPolicy +- [x] 할인 조건을 만족하면 10% 할인한다. +- [x] 10, 20, 30일에 할인 조건을 만족한다는 할인 조건을 가지고 있다. + +### ScreeningTimeDiscountPolicy +- [x] 할인 조건을 만족하면 2000원 할인한다. +- [x] 조조, 야간 할인 조건을 가지고 있다. + +### DiscountCondition +- [x] 영화 예매에 대해 할인할 수 있는지 판단할 수 있다. + +### DayDiscountCondition +- [x] 상영 날짜로 할인할 수 있는지 판단한다. + +### ScreeningTimeDiscountCondition +- [x] 상영 시간으로 할인할 수 있는지 판단한다. + +### Seat +- [x] 좌석의 행과 열을 알아야 한다. +- [x] 행은 1~4, 열은 1~5를 만족한다. +- [x] 좌석에 해당하는 금액을 구한다. + +### SeatType +- [x] 좌석타입에 해당하는 금액을 가지고 있다. + +## View + +### MovieListActivity +- [x] 모든 영화의 제목, 상영 기간, 러닝타임, 이미지를 보여준다. +- [x] 영화마다 예매할 수 있는 버튼이 존재한다. + +### ReservationActivity +- [x] 영화의 이미지, 제목, 상영 기간, 상영 시간, 러닝타임, 상세정보를 보여준다. +- [x] 상영 기간과 상영 시간은 스피너로 선택할 수 있다. +- [x] 예매 인원을 조절할 수 있는 버튼이 존재한다. +- [x] 클릭하면 예매 정보를 보여주는 화면을 띄우는 예매 완료 버튼이 존재한다. + +### ReservationCompletedActivity +- [x] 영화 제목, 상영일, 상영 시간, 예매 인원, 예매 금액을 보여준다. + +## Repository + +### MovieRepository +- [x] 영화 데이터들을 조회할 수 있다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4692cbe6e..4cd178d07 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") } android { @@ -33,13 +34,19 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + viewBinding = true + } } dependencies { + implementation(project(":domain")) 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") + implementation("androidx.fragment:fragment-ktx:1.4.0") + implementation("androidx.preference:preference:1.2.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/androidTest/java/woowacourse/movie/.gitkeep b/app/src/androidTest/java/woowacourse/movie/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/src/androidTest/java/woowacourse/movie/view/MovieMainActivityTest.kt b/app/src/androidTest/java/woowacourse/movie/view/MovieMainActivityTest.kt new file mode 100644 index 000000000..c755a402b --- /dev/null +++ b/app/src/androidTest/java/woowacourse/movie/view/MovieMainActivityTest.kt @@ -0,0 +1,38 @@ +package woowacourse.movie.view + +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.isDisplayed +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.view.moviemain.MovieMainActivity + +@RunWith(AndroidJUnit4::class) +class MovieMainActivityTest { + @get:Rule + val mActivityTestRule = ActivityScenarioRule(MovieMainActivity::class.java) + + @Test + fun 예매내역_버튼을_누르면_예매내역_Fragment로_바뀐다() { + onView(withId(R.id.action_reservation_list)).perform(click()) + onView(withId(R.id.recyclerview)).check(matches(isDisplayed())) + } + + @Test + fun 홈_버튼을_누르면_홈_Fragment로_바뀐다() { + onView(withId(R.id.action_home)).perform(click()) + onView(withId(R.id.movie_recyclerview)).check(matches(isDisplayed())) + } + + @Test + fun 설정_버튼을_누르면_설정_Fragment로_바뀐다() { + onView(withId(R.id.action_setting)).perform(click()) + onView(withId(R.id.setting_toggle)).check(matches(isDisplayed())) + } +} diff --git a/app/src/androidTest/java/woowacourse/movie/view/ReservationActivityTest.kt b/app/src/androidTest/java/woowacourse/movie/view/ReservationActivityTest.kt new file mode 100644 index 000000000..c8d160553 --- /dev/null +++ b/app/src/androidTest/java/woowacourse/movie/view/ReservationActivityTest.kt @@ -0,0 +1,59 @@ +package woowacourse.movie.view + +import androidx.test.core.app.ApplicationProvider +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.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +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.view.model.MovieListModel +import java.time.LocalDate + +@RunWith(AndroidJUnit4::class) +class ReservationActivityTest { + + private val movie = MovieListModel.MovieUiModel( + "해리 포터와 마법사의 돌", + LocalDate.of(2024, 3, 1), + LocalDate.of(2024, 3, 31), + 152, + R.drawable.harry_potter1_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ) + + private val intent = ReservationActivity.newIntent( + ApplicationProvider.getApplicationContext(), + movie + ) + + @get:Rule + val activityRule = ActivityScenarioRule(intent) + + @Test + fun 영화_제목을_표시한다() { + onView(withId(R.id.movie_title)).check(matches(withText("해리 포터와 마법사의 돌"))) + } + + @Test + fun 처음_표시되는_인원수는_1이다() { + onView(withId(R.id.people_count)).check(matches(withText("1"))) + } + + @Test + fun 플러스_버튼을_한_번_클릭하면_인원수는_2이다() { + onView(withId(R.id.plus_button)).perform(click()) + onView(withId(R.id.people_count)).check(matches(withText("2"))) + } + + @Test + fun 초기_인원_1인_경우_마이너스_버튼을_눌러도_인원수는_1이다() { + onView(withId(R.id.minus_button)).perform(click()) + onView(withId(R.id.people_count)).check(matches(withText("1"))) + } +} diff --git a/app/src/androidTest/java/woowacourse/movie/view/SeatSelectionActivityTest.kt b/app/src/androidTest/java/woowacourse/movie/view/SeatSelectionActivityTest.kt new file mode 100644 index 000000000..e328fbf1f --- /dev/null +++ b/app/src/androidTest/java/woowacourse/movie/view/SeatSelectionActivityTest.kt @@ -0,0 +1,103 @@ +package woowacourse.movie.view + +import androidx.test.core.app.ApplicationProvider +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.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled +import androidx.test.espresso.matcher.ViewMatchers.isNotSelected +import androidx.test.espresso.matcher.ViewMatchers.isSelected +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import woowacourse.movie.R +import woowacourse.movie.domain.Minute +import woowacourse.movie.domain.Movie +import woowacourse.movie.view.mapper.toUiModel +import woowacourse.movie.view.model.ReservationOptions +import woowacourse.movie.view.seatselection.SeatSelectionActivity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +@RunWith(AndroidJUnit4::class) +@LargeTest +class SeatSelectionActivityTest { + + private val reservationOptions = ReservationOptions( + "해리 포터와 마법사의 돌", + LocalDateTime.of(LocalDate.of(2024, 3, 1), LocalTime.of(13, 0)), + 2 + ) + + private val movie = Movie( + "해리 포터와 마법사의 돌", + LocalDate.of(2024, 3, 1), + LocalDate.of(2024, 3, 31), + Minute(152), + R.drawable.harry_potter1_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ) + + private val intent = SeatSelectionActivity.newIntent( + ApplicationProvider.getApplicationContext(), + reservationOptions, + movie.toUiModel() + ) + + @get:Rule + val activityRule = ActivityScenarioRule(intent) + + @Test + fun 영화_제목을_표시한다() { + onView(withId(R.id.movie_title_textview)) + .check(matches(withText("해리 포터와 마법사의 돌"))) + } + + @Test + fun 한_번_클릭하면_좌석이_선택된다() { + onView(withText("A1")).perform(click()).check(matches(isSelected())) + } + + @Test + fun 두_번_클릭하면_좌석_선택이_해제된다() { + onView(withText("A1")).perform(click()) + onView(withText("A1")).perform(click()).check(matches(isNotSelected())) + } + + @Test + fun 인원수에_해당하는_좌석이_모두_선택되지_않았다면_확인_버튼은_비활성화_상태다() { + onView(withText("A1")).perform(click()) + onView(withId(R.id.confirm_reservation_button)) + .check(matches(isNotEnabled())) + } + + @Test + fun 인원수에_해당하는_좌석이_모두_선택되었다면_확인_버튼은_활성화_상태다() { + onView(withText("A1")).perform(click()) + onView(withText("A2")).perform(click()) + onView(withId(R.id.confirm_reservation_button)) + .check(matches(isEnabled())) + } + + @Test + fun 인원수에_해당하는_좌석이_모두_선택되었다면_최종_금액이_표시된다() { + onView(withText("A1")).perform(click()) + onView(withText("A2")).perform(click()) + onView(withId(R.id.reservation_fee_textview)).check(matches(withText("20,000원"))) + } + + @Test + fun 좌석_선택을_해제하여_인원수에_해당하는_좌석이_모두_선택되지_않았다면_최종_금액은_0원으로_표시된다() { + onView(withText("A1")).perform(click()) + onView(withText("A2")).perform(click()) + onView(withText("A1")).perform(click()) + onView(withId(R.id.reservation_fee_textview)).check(matches(withText("0원"))) + } +} diff --git a/app/src/androidTest/java/woowacourse/movie/view/SettingFragmentTest.kt b/app/src/androidTest/java/woowacourse/movie/view/SettingFragmentTest.kt new file mode 100644 index 000000000..79ae36f48 --- /dev/null +++ b/app/src/androidTest/java/woowacourse/movie/view/SettingFragmentTest.kt @@ -0,0 +1,61 @@ +package woowacourse.movie.view + +import android.content.Context +import android.content.SharedPreferences +import androidx.fragment.app.commit +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import woowacourse.movie.R +import woowacourse.movie.view.moviemain.MovieMainActivity +import woowacourse.movie.view.moviemain.setting.SettingFragment + +@RunWith(AndroidJUnit4::class) +class SettingFragmentTest { + @get:Rule + val mActivityTestRule = ActivityScenarioRule(MovieMainActivity::class.java) + + @Before + fun setup() { + ActivityScenario.launch(MovieMainActivity::class.java).onActivity { + it.supportFragmentManager.commit { + replace(R.id.fragment_container_view, SettingFragment()) + } + } + } + + @Test + fun 설정_Fragment에서_저장된_세팅값이_false면_토글도_꺼져있다() { + setSharedPreferences(false) + onView(ViewMatchers.withId(R.id.action_setting)).perform(ViewActions.click()) + onView(ViewMatchers.withId(R.id.setting_toggle)) + .check(ViewAssertions.matches(ViewMatchers.isNotChecked())) + } + + @Test + fun 설정_Fragment에서_저장된_세팅값이_true면_토글도_켜져있다() { + setSharedPreferences(true) + onView(ViewMatchers.withId(R.id.action_setting)).perform(ViewActions.click()) + onView(ViewMatchers.withId(R.id.setting_toggle)) + .check(ViewAssertions.matches(ViewMatchers.isChecked())) + } + + private fun setSharedPreferences(isAlarmOn: Boolean) { + val targetContext: Context = InstrumentationRegistry.getInstrumentation().targetContext + + val preferencesEditor: SharedPreferences.Editor = PreferenceManager.getDefaultSharedPreferences(targetContext).edit() + preferencesEditor.clear() + preferencesEditor.putBoolean(SettingFragment.IS_ALARM_ON, isAlarmOn) + preferencesEditor.commit() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ad1a01d9..4600f42a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + xmlns:tools="http://schemas.android.com/tools" > + + + tools:targetApi="31" > + + + + + + + android:name=".view.moviemain.MovieMainActivity" + android:exported="true" > diff --git a/app/src/main/java/woowacourse/movie/MainActivity.kt b/app/src/main/java/woowacourse/movie/MainActivity.kt deleted file mode 100644 index 215e97c4d..000000000 --- a/app/src/main/java/woowacourse/movie/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package woowacourse.movie - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} diff --git a/app/src/main/java/woowacourse/movie/data/MovieMockRepository.kt b/app/src/main/java/woowacourse/movie/data/MovieMockRepository.kt new file mode 100644 index 000000000..725eb834a --- /dev/null +++ b/app/src/main/java/woowacourse/movie/data/MovieMockRepository.kt @@ -0,0 +1,51 @@ +package woowacourse.movie.data + +import woowacourse.movie.R +import woowacourse.movie.domain.Minute +import woowacourse.movie.domain.Movie +import woowacourse.movie.domain.repository.MovieRepository +import java.time.LocalDate + +object MovieMockRepository : MovieRepository { + + private val movies = List(1000) { + listOf( + Movie( + "해리 포터와 마법사의 돌", + LocalDate.of(2024, 3, 1), + LocalDate.of(2024, 3, 31), + Minute(152), + R.drawable.harry_potter1_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ), + Movie( + "해리 포터와 비밀의 방", + LocalDate.of(2024, 4, 1), + LocalDate.of(2024, 4, 28), + Minute(162), + R.drawable.harry_potter2_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ), + Movie( + "해리 포터와 아즈카반의 죄수", + LocalDate.of(2024, 5, 1), + LocalDate.of(2024, 5, 31), + Minute(141), + R.drawable.harry_potter3_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ), + Movie( + "해리 포터와 불의 잔", + LocalDate.of(2024, 6, 1), + LocalDate.of(2024, 6, 30), + Minute(157), + R.drawable.harry_potter4_poster, + "《해리 포터와 마법사의 돌》은 2001년 J. K. 롤링의 동명 소설을 원작으로 하여 만든, 영국과 미국 합작, 판타지 영화이다. 해리포터 시리즈 영화 8부작 중 첫 번째에 해당하는 작품이다. 크리스 콜럼버스가 감독을 맡았다." + ) + ) + }.flatten() + + override fun findAll(): List { + return movies.toList() + } +} diff --git a/app/src/main/java/woowacourse/movie/data/ReservationMockRepository.kt b/app/src/main/java/woowacourse/movie/data/ReservationMockRepository.kt new file mode 100644 index 000000000..e044da84b --- /dev/null +++ b/app/src/main/java/woowacourse/movie/data/ReservationMockRepository.kt @@ -0,0 +1,16 @@ +package woowacourse.movie.data + +import woowacourse.movie.domain.Reservation +import woowacourse.movie.domain.repository.ReservationRepository + +object ReservationMockRepository : ReservationRepository { + + private val reservations = mutableListOf() + override fun add(reservation: Reservation) { + reservations.add(reservation) + } + + override fun findAll(): List { + return reservations.toList() + } +} diff --git a/app/src/main/java/woowacourse/movie/util/DateTimeFormatter.kt b/app/src/main/java/woowacourse/movie/util/DateTimeFormatter.kt new file mode 100644 index 000000000..8d0982c00 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/util/DateTimeFormatter.kt @@ -0,0 +1,6 @@ +package woowacourse.movie.util + +import java.time.format.DateTimeFormatter + +val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") +val TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") diff --git a/app/src/main/java/woowacourse/movie/util/ParcelableExtension.kt b/app/src/main/java/woowacourse/movie/util/ParcelableExtension.kt new file mode 100644 index 000000000..79e5b539b --- /dev/null +++ b/app/src/main/java/woowacourse/movie/util/ParcelableExtension.kt @@ -0,0 +1,22 @@ +package woowacourse.movie.util + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable + +inline fun Intent.getParcelableCompat(key: String): T? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return getParcelableExtra(key, T::class.java) + } + @Suppress("DEPRECATION") + return getParcelableExtra(key) as? T +} + +inline fun Bundle.getSerializableCompat(key: String): T? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return getSerializable(key, T::class.java) + } + @Suppress("DEPRECATION") + return getSerializable(key) as? T +} diff --git a/app/src/main/java/woowacourse/movie/view/AlarmController.kt b/app/src/main/java/woowacourse/movie/view/AlarmController.kt new file mode 100644 index 000000000..9bc6bd018 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/AlarmController.kt @@ -0,0 +1,60 @@ +package woowacourse.movie.view + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import woowacourse.movie.view.model.ReservationUiModel +import woowacourse.movie.view.seatselection.AlarmReceiver +import java.time.ZoneId + +class AlarmController( + private val context: Context +) { + + fun registerAlarms(reservations: List, minuteInterval: Long) { + reservations.forEach { + registerAlarm(it, minuteInterval) + } + } + + fun registerAlarm(reservation: ReservationUiModel, minuteInterval: Long) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = getPendingIntent(reservation) + + alarmManager.set( + AlarmManager.RTC_WAKEUP, + reservation.screeningDateTime.minusMinutes(minuteInterval) + .atZone(ZoneId.systemDefault()) + .toEpochSecond() * 1000L, + pendingIntent, + ) + } + + private fun getPendingIntent(reservation: ReservationUiModel): PendingIntent { + return Intent(context, AlarmReceiver::class.java).let { + it.putExtra(AlarmReceiver.RESERVATION, reservation) + PendingIntent.getBroadcast( + context, + ALARM_REQUEST_CODE, + it, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + } + + fun cancelAlarms() { + val pendingIntent = PendingIntent.getBroadcast( + context, + ALARM_REQUEST_CODE, + Intent(context, AlarmReceiver::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pendingIntent) + } + + companion object { + private const val ALARM_REQUEST_CODE = 100 + } +} diff --git a/app/src/main/java/woowacourse/movie/view/ReservationActivity.kt b/app/src/main/java/woowacourse/movie/view/ReservationActivity.kt new file mode 100644 index 000000000..25b215c02 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/ReservationActivity.kt @@ -0,0 +1,211 @@ +package woowacourse.movie.view + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.appcompat.app.AppCompatActivity +import woowacourse.movie.R +import woowacourse.movie.databinding.ActivityReservationBinding +import woowacourse.movie.domain.Reservation +import woowacourse.movie.domain.ScreeningTime +import woowacourse.movie.util.DATE_FORMATTER +import woowacourse.movie.util.getParcelableCompat +import woowacourse.movie.util.getSerializableCompat +import woowacourse.movie.view.model.MovieListModel.MovieUiModel +import woowacourse.movie.view.model.ReservationOptions +import woowacourse.movie.view.seatselection.SeatSelectionActivity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class ReservationActivity : AppCompatActivity() { + + private lateinit var binding: ActivityReservationBinding + + private var peopleCountSaved = 1 + private lateinit var selectedScreeningDate: LocalDate + private lateinit var selectedScreeningTime: LocalTime + private val movie: MovieUiModel by lazy { initMovieFromIntent() } + private var timeSpinnerPosition = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityReservationBinding.inflate(layoutInflater) + setContentView(binding.root) + + initViewData() + initSpinner() + initPeopleCountAdjustButtonClickListener() + initReserveButtonClickListener() + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + override fun onResume() { + super.onResume() + binding.peopleCount.text = peopleCountSaved.toString() + } + + private fun initMovieFromIntent(): MovieUiModel { + val movie = intent.getParcelableCompat(MOVIE) + requireNotNull(movie) { "인텐트로 받아온 데이터가 널일 수 없습니다." } + return movie + } + + private fun initViewData() { + binding.apply { + moviePoster.setImageResource(movie.posterResourceId) + movieTitle.text = movie.title + movieScreeningDate.text = getString(R.string.screening_date_format).format( + movie.screeningStartDate.format(DATE_FORMATTER), + movie.screeningEndDate.format(DATE_FORMATTER), + ) + movieRunningTime.text = + getString(R.string.running_time_format).format(movie.runningTime) + movieSummary.text = movie.summary + } + } + + private fun initSpinner() { + selectedScreeningDate = movie.screeningStartDate + selectedScreeningTime = ScreeningTime(selectedScreeningDate).getFirstScreeningTime() + + val screeningDates = movie.getAllScreeningDates() + + val dateSpinnerAdapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + screeningDates, + ) + + binding.dateSpinner.apply { + adapter = dateSpinnerAdapter + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + selectedScreeningDate = screeningDates[position] + initTimeSpinner(timeSpinnerPosition) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + } + + private fun initTimeSpinner(selectedPosition: Int?) { + val screeningTimes = ScreeningTime(selectedScreeningDate).getAllScreeningTimes() + + val timeSpinnerAdapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + screeningTimes, + ) + binding.timeSpinner.apply { + adapter = timeSpinnerAdapter + + if (selectedPosition != null) { + this.setSelection(selectedPosition, false) + } + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + selectedScreeningTime = screeningTimes[position] + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + } + + private fun initPeopleCountAdjustButtonClickListener() { + binding.apply { + minusButton.setOnClickListener { + decreasePeopleCount() + } + plusButton.setOnClickListener { + increasePeopleCount() + } + } + } + + private fun ActivityReservationBinding.decreasePeopleCount() { + if (peopleCountSaved > Reservation.MIN_PEOPLE_COUNT) { + peopleCountSaved-- + peopleCount.text = peopleCountSaved.toString() + } + } + + private fun ActivityReservationBinding.increasePeopleCount() { + if (peopleCountSaved < Reservation.MAX_PEOPLE_COUNT) { + peopleCountSaved++ + peopleCount.text = peopleCountSaved.toString() + } + } + + private fun initReserveButtonClickListener() { + binding.reservationButton.setOnClickListener { + val reservationOptions = ReservationOptions( + movie.title, + LocalDateTime.of(selectedScreeningDate, selectedScreeningTime), + peopleCountSaved, + ) + startActivity(SeatSelectionActivity.newIntent(this, reservationOptions, movie)) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.apply { + putInt(PEOPLE_COUNT, peopleCountSaved) + putSerializable(SELECTED_DATE, selectedScreeningDate) + putSerializable(SELECTED_TIME, selectedScreeningTime) + putInt(SELECTED_TIME_POSITION, binding.timeSpinner.selectedItemPosition) + } + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + + peopleCountSaved = savedInstanceState.getInt(PEOPLE_COUNT) + timeSpinnerPosition = savedInstanceState.getInt(SELECTED_TIME_POSITION) + + savedInstanceState.getSerializableCompat(SELECTED_DATE)?.run { + selectedScreeningDate = this + } + savedInstanceState.getSerializableCompat(SELECTED_TIME)?.run { + selectedScreeningTime = this + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> finish() + } + return super.onOptionsItemSelected(item) + } + + companion object { + private const val PEOPLE_COUNT = "PEOPLE_COUNT" + private const val SELECTED_DATE = "SELECTED_DATE" + private const val SELECTED_TIME = "SELECTED_TIME" + private const val SELECTED_TIME_POSITION = "SELECTED_TIME_POSITION" + private const val MOVIE = "MOVIE" + fun newIntent(context: Context, movie: MovieUiModel): Intent { + val intent = Intent(context, ReservationActivity::class.java) + intent.putExtra(MOVIE, movie) + return intent + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/ReservationCompletedActivity.kt b/app/src/main/java/woowacourse/movie/view/ReservationCompletedActivity.kt new file mode 100644 index 000000000..993ab2bd4 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/ReservationCompletedActivity.kt @@ -0,0 +1,150 @@ +package woowacourse.movie.view + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import woowacourse.movie.R +import woowacourse.movie.databinding.ActivityReservationCompletedBinding +import woowacourse.movie.util.DATE_FORMATTER +import woowacourse.movie.util.TIME_FORMATTER +import woowacourse.movie.util.getParcelableCompat +import woowacourse.movie.view.model.ReservationUiModel +import woowacourse.movie.view.moviemain.MovieMainActivity +import woowacourse.movie.view.moviemain.setting.SettingFragment +import woowacourse.movie.view.seatselection.AlarmReceiver +import java.text.DecimalFormat + +class ReservationCompletedActivity : AppCompatActivity() { + + private lateinit var binding: ActivityReservationCompletedBinding + private lateinit var sharedPreferences: SharedPreferences + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityReservationCompletedBinding.inflate(layoutInflater) + setContentView(binding.root) + + val reservation = intent.getParcelableCompat(RESERVATION) + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + val isAlarmOn = sharedPreferences.getBoolean(SettingFragment.IS_ALARM_ON, false) + + requestNotificationPermission() + + reservation?.let { + initViewData(it) + if (isAlarmOn) setAlarm(reservation, SettingFragment.ALARM_MINUTE_INTERVAL) + } + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val intent = Intent(this@ReservationCompletedActivity, MovieMainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + startActivity(intent) + } + }, + ) + } + + private fun setAlarm(reservation: ReservationUiModel, alarmMinuteInterval: Long) { + val alarmController = AlarmController(this) + createChannel() + alarmController.registerAlarm(reservation, alarmMinuteInterval) + } + + private fun initViewData(reservation: ReservationUiModel) { + binding.apply { + movieTitle.text = reservation.title + movieScreeningDate.text = getString( + R.string.datetime_with_space, + reservation.screeningDateTime.format(DATE_FORMATTER), + reservation.screeningDateTime.format(TIME_FORMATTER), + ) + peopleCount.text = getString(R.string.reservation_people_count_format) + .format( + getString(R.string.general_person), + reservation.peopleCount, + reservation.seats.joinToString(), + ) + totalPrice.text = + getString(R.string.total_price_format).format(DECIMAL_FORMAT.format(reservation.finalReservationFee)) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + val intent = Intent(this, MovieMainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + startActivity(intent) + } + } + return super.onOptionsItemSelected(item) + } + + private fun createChannel() { + val name = "Reservation Notification" + val channel = NotificationChannel( + AlarmReceiver.CHANNEL_ID, + name, + NotificationManager.IMPORTANCE_DEFAULT, + ) + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun requestNotificationPermission() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + val editor: SharedPreferences.Editor = sharedPreferences.edit() + if (isGranted) { + editor.putBoolean(SettingFragment.IS_ALARM_ON, true).apply() + return@registerForActivityResult + } + editor.putBoolean(SettingFragment.IS_ALARM_ON, false).apply() + } + + companion object { + private const val RESERVATION = "RESERVATION" + private val DECIMAL_FORMAT = DecimalFormat("#,###") + const val REQUEST_CODE = 101 + + fun newIntent(context: Context, reservation: ReservationUiModel): Intent { + val intent = Intent(context, ReservationCompletedActivity::class.java) + intent.putExtra(RESERVATION, reservation) + return intent + } + + fun getPendingIntent(context: Context, reservation: ReservationUiModel): PendingIntent { + return PendingIntent.getActivity( + context, + REQUEST_CODE, + newIntent(context, reservation), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/mapper/MovieMapper.kt b/app/src/main/java/woowacourse/movie/view/mapper/MovieMapper.kt new file mode 100644 index 000000000..4ff73c13b --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/mapper/MovieMapper.kt @@ -0,0 +1,23 @@ +package woowacourse.movie.view.mapper + +import woowacourse.movie.domain.Minute +import woowacourse.movie.domain.Movie +import woowacourse.movie.view.model.MovieListModel.MovieUiModel + +fun Movie.toUiModel(): MovieUiModel = MovieUiModel( + title, + screeningStartDate, + screeningEndDate, + runningTime.value, + posterResourceId, + summary +) + +fun MovieUiModel.toDomainModel(): Movie = Movie( + title, + screeningStartDate, + screeningEndDate, + Minute(runningTime), + posterResourceId, + summary +) diff --git a/app/src/main/java/woowacourse/movie/view/mapper/ReservationMapper.kt b/app/src/main/java/woowacourse/movie/view/mapper/ReservationMapper.kt new file mode 100644 index 000000000..af2a2b391 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/mapper/ReservationMapper.kt @@ -0,0 +1,12 @@ +package woowacourse.movie.view.mapper + +import woowacourse.movie.domain.Reservation +import woowacourse.movie.view.model.ReservationUiModel + +fun Reservation.toUiModel(): ReservationUiModel = ReservationUiModel( + movieTitle, + screeningDateTime, + seats.size, + seats.map { it.toUiModel().name }, + finalReservationFee.amount +) diff --git a/app/src/main/java/woowacourse/movie/view/mapper/SeatMapper.kt b/app/src/main/java/woowacourse/movie/view/mapper/SeatMapper.kt new file mode 100644 index 000000000..0139dd451 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/mapper/SeatMapper.kt @@ -0,0 +1,15 @@ +package woowacourse.movie.view.mapper + +import woowacourse.movie.R +import woowacourse.movie.domain.Seat +import woowacourse.movie.domain.SeatType +import woowacourse.movie.view.model.SeatUiModel + +fun Seat.toUiModel(): SeatUiModel = SeatUiModel( + ('A' + row - 1).toString() + "$column", + when (this.type) { + SeatType.BType -> R.color.purple_700 + SeatType.SType -> R.color.green + SeatType.AType -> R.color.blue + } +) diff --git a/app/src/main/java/woowacourse/movie/view/model/MovieListModel.kt b/app/src/main/java/woowacourse/movie/view/model/MovieListModel.kt new file mode 100644 index 000000000..23798be9f --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/model/MovieListModel.kt @@ -0,0 +1,35 @@ +package woowacourse.movie.view.model + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +sealed class MovieListModel { + + data class MovieAdModel( + @DrawableRes val banner: Int, + val url: String + ) : MovieListModel() + + @Parcelize + data class MovieUiModel( + val title: String, + val screeningStartDate: LocalDate, + val screeningEndDate: LocalDate, + val runningTime: Int, + val posterResourceId: Int, + val summary: String + ) : Parcelable, MovieListModel() { + fun getAllScreeningDates(): List { + val screeningDates = mutableListOf() + var screeningDate = screeningStartDate + repeat(ChronoUnit.DAYS.between(screeningStartDate, screeningEndDate).toInt() + 1) { + screeningDates.add(screeningDate) + screeningDate = screeningDate.plusDays(1) + } + return screeningDates + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/model/ReservationOptions.kt b/app/src/main/java/woowacourse/movie/view/model/ReservationOptions.kt new file mode 100644 index 000000000..7a75a96a0 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/model/ReservationOptions.kt @@ -0,0 +1,12 @@ +package woowacourse.movie.view.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.time.LocalDateTime + +@Parcelize +data class ReservationOptions( + val title: String, + val screeningDateTime: LocalDateTime, + val peopleCount: Int +) : Parcelable diff --git a/app/src/main/java/woowacourse/movie/view/model/ReservationUiModel.kt b/app/src/main/java/woowacourse/movie/view/model/ReservationUiModel.kt new file mode 100644 index 000000000..736494f9a --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/model/ReservationUiModel.kt @@ -0,0 +1,14 @@ +package woowacourse.movie.view.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.time.LocalDateTime + +@Parcelize +data class ReservationUiModel( + val title: String, + val screeningDateTime: LocalDateTime, + val peopleCount: Int, + val seats: List, + val finalReservationFee: Int +) : Parcelable diff --git a/app/src/main/java/woowacourse/movie/view/model/SeatUiModel.kt b/app/src/main/java/woowacourse/movie/view/model/SeatUiModel.kt new file mode 100644 index 000000000..5f6a22728 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/model/SeatUiModel.kt @@ -0,0 +1,6 @@ +package woowacourse.movie.view.model + +class SeatUiModel( + val name: String, + val color: Int +) diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/MovieMainActivity.kt b/app/src/main/java/woowacourse/movie/view/moviemain/MovieMainActivity.kt new file mode 100644 index 000000000..4f49d69e5 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/MovieMainActivity.kt @@ -0,0 +1,61 @@ +package woowacourse.movie.view.moviemain + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import com.google.android.material.bottomnavigation.BottomNavigationView +import woowacourse.movie.R +import woowacourse.movie.view.moviemain.movielist.MovieListFragment +import woowacourse.movie.view.moviemain.reservationlist.ReservationListFragment +import woowacourse.movie.view.moviemain.setting.SettingFragment + +class MovieMainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_movie_main) + + val navigation = findViewById(R.id.navigation_view) + + navigation.setOnItemSelectedListener { item -> + when (item.itemId) { + R.id.action_reservation_list -> { + val fragment = supportFragmentManager.findFragmentByTag(TAG_RESERVATION_LIST) as? ReservationListFragment ?: ReservationListFragment().also { addFragment(it, TAG_RESERVATION_LIST) } + replaceFragment(fragment) + return@setOnItemSelectedListener true + } + R.id.action_home -> { + val fragment = supportFragmentManager.findFragmentByTag(TAG_MOVIE_LIST) as? MovieListFragment ?: MovieListFragment().also { addFragment(it, TAG_MOVIE_LIST) } + replaceFragment(fragment) + return@setOnItemSelectedListener true + } + R.id.action_setting -> { + val fragment = supportFragmentManager.findFragmentByTag(TAG_MOVIE_LIST) as? SettingFragment ?: SettingFragment().also { addFragment(it, TAG_SETTING) } + replaceFragment(fragment) + return@setOnItemSelectedListener true + } + else -> return@setOnItemSelectedListener false + } + } + navigation.selectedItemId = R.id.action_home + } + + private fun replaceFragment(fragment: Fragment) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_view, fragment) + } + } + + private fun addFragment(fragment: Fragment, tag: String) { + supportFragmentManager.commit { + add(fragment, tag) + } + } + + companion object { + private const val TAG_RESERVATION_LIST = "RESERVATION_LIST" + private const val TAG_MOVIE_LIST = "MOVIE_LIST" + private const val TAG_SETTING = "SETTING" + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieAdViewHolder.kt b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieAdViewHolder.kt new file mode 100644 index 000000000..fa0440642 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieAdViewHolder.kt @@ -0,0 +1,18 @@ +package woowacourse.movie.view.moviemain.movielist + +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.databinding.MovieAdItemBinding +import woowacourse.movie.view.model.MovieListModel + +class MovieAdViewHolder( + private val binding: MovieAdItemBinding, + private val onViewClick: MovieListAdapter.OnItemClick +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(ad: MovieListModel.MovieAdModel) { + binding.adImageview.setImageResource(ad.banner) + binding.adImageview.setOnClickListener { + onViewClick.onClick(ad) + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieItemViewHolder.kt b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieItemViewHolder.kt new file mode 100644 index 000000000..440f92f2a --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieItemViewHolder.kt @@ -0,0 +1,30 @@ +package woowacourse.movie.view.moviemain.movielist + +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.R +import woowacourse.movie.databinding.MovieItemBinding +import woowacourse.movie.util.DATE_FORMATTER +import woowacourse.movie.view.model.MovieListModel.MovieUiModel + +class MovieItemViewHolder( + private val binding: MovieItemBinding, + private val onViewClick: MovieListAdapter.OnItemClick +) : RecyclerView.ViewHolder(binding.root) { + fun bind(movie: MovieUiModel) { + binding.apply { + val context = binding.root.context + moviePoster.setImageResource(movie.posterResourceId) + movieTitle.text = movie.title + movieScreeningDate.text = + context.resources.getString(R.string.screening_date_format).format( + movie.screeningStartDate.format(DATE_FORMATTER), + movie.screeningEndDate.format(DATE_FORMATTER) + ) + movieRunningTime.text = context.resources.getString(R.string.running_time_format) + .format(movie.runningTime) + } + binding.reserveNowButton.setOnClickListener { + onViewClick.onClick(movie) + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListAdapter.kt b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListAdapter.kt new file mode 100644 index 000000000..474c9ca41 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListAdapter.kt @@ -0,0 +1,52 @@ +package woowacourse.movie.view.moviemain.movielist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.databinding.MovieAdItemBinding +import woowacourse.movie.databinding.MovieItemBinding +import woowacourse.movie.view.model.MovieListModel +import woowacourse.movie.view.model.MovieListModel.MovieAdModel +import woowacourse.movie.view.model.MovieListModel.MovieUiModel + +class MovieListAdapter( + private val dataList: List, + private val onItemClick: OnItemClick +) : RecyclerView.Adapter() { + + fun interface OnItemClick { + fun onClick(item: MovieListModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (MovieListViewType.values()[viewType]) { + MovieListViewType.MOVIE_ITEM -> { + val view = LayoutInflater.from(parent.context).inflate(MovieListViewType.MOVIE_ITEM.id, parent, false) + MovieItemViewHolder(MovieItemBinding.bind(view), onItemClick) + } + MovieListViewType.AD_ITEM -> { + val view = LayoutInflater.from(parent.context).inflate(MovieListViewType.AD_ITEM.id, parent, false) + MovieAdViewHolder(MovieAdItemBinding.bind(view), onItemClick) + } + } + } + + override fun getItemCount(): Int = dataList.size + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = dataList[position] + when (holder) { + is MovieItemViewHolder -> { + holder.bind(item as MovieUiModel) + } + is MovieAdViewHolder -> { + holder.bind(item as MovieAdModel) + } + } + } + + override fun getItemViewType(position: Int): Int = when (dataList[position]) { + is MovieUiModel -> MovieListViewType.MOVIE_ITEM.ordinal + is MovieAdModel -> MovieListViewType.AD_ITEM.ordinal + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListFragment.kt b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListFragment.kt new file mode 100644 index 000000000..b69a00fbf --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListFragment.kt @@ -0,0 +1,69 @@ +package woowacourse.movie.view.moviemain.movielist + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.R +import woowacourse.movie.data.MovieMockRepository +import woowacourse.movie.domain.Movie +import woowacourse.movie.view.ReservationActivity +import woowacourse.movie.view.mapper.toUiModel +import woowacourse.movie.view.model.MovieListModel + +class MovieListFragment : Fragment(R.layout.fragment_movie_list) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val movies = MovieMockRepository.findAll() + val dataList = generateMovieListData(movies) + + val movieAdapter = MovieListAdapter(dataList, ::onClick) + val movieListView = view.findViewById(R.id.movie_recyclerview) + movieListView.adapter = movieAdapter + } + + private fun onClick(item: MovieListModel) { + when (item) { + is MovieListModel.MovieUiModel -> { + val intent = ReservationActivity.newIntent(requireContext(), item) + startActivity(intent) + } + is MovieListModel.MovieAdModel -> { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(item.url)) + startActivity(intent) + } + } + } + + private fun generateMovieListData(movies: List): List { + val ad = MovieListModel.MovieAdModel( + R.drawable.woowacourse_banner, + "https://woowacourse.github.io/", + ) + + return mixMovieAdData(movies, ad, AD_POST_INTERVAL) + } + + private fun mixMovieAdData( + movies: List, + ad: MovieListModel.MovieAdModel, + adPostInterval: Int, + ): List { + val dataList = mutableListOf() + movies.forEachIndexed { index, movie -> + if (index % adPostInterval == adPostInterval - 1) { + dataList.add(movie.toUiModel()) + dataList.add(ad) + return@forEachIndexed + } + dataList.add(movie.toUiModel()) + } + return dataList + } + + companion object { + private const val AD_POST_INTERVAL = 3 + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListViewType.kt b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListViewType.kt new file mode 100644 index 000000000..409d390dc --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/movielist/MovieListViewType.kt @@ -0,0 +1,8 @@ +package woowacourse.movie.view.moviemain.movielist + +import woowacourse.movie.R + +enum class MovieListViewType(val id: Int) { + MOVIE_ITEM(R.layout.movie_item), + AD_ITEM(R.layout.movie_ad_item) +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationItemViewHolder.kt b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationItemViewHolder.kt new file mode 100644 index 000000000..7cfc944e3 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationItemViewHolder.kt @@ -0,0 +1,29 @@ +package woowacourse.movie.view.moviemain.reservationlist + +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.R +import woowacourse.movie.databinding.ReservationItemBinding +import woowacourse.movie.util.DATE_FORMATTER +import woowacourse.movie.util.TIME_FORMATTER +import woowacourse.movie.view.model.ReservationUiModel + +class ReservationItemViewHolder( + private val binding: ReservationItemBinding, + private val onItemClick: ReservationListAdapter.OnItemClick +) : + RecyclerView.ViewHolder(binding.root) { + fun bind(reservation: ReservationUiModel) { + val context = binding.root.context + with(binding) { + reservationDatetime.text = context.getString( + R.string.datetime_with_line, + reservation.screeningDateTime.format(DATE_FORMATTER), + reservation.screeningDateTime.format(TIME_FORMATTER), + ) + movieTitle.text = reservation.title + } + binding.reservationLayout.setOnClickListener { + onItemClick.onClick(reservation) + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListAdapter.kt b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListAdapter.kt new file mode 100644 index 000000000..e97fae468 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListAdapter.kt @@ -0,0 +1,31 @@ +package woowacourse.movie.view.moviemain.reservationlist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.R +import woowacourse.movie.databinding.ReservationItemBinding +import woowacourse.movie.view.model.ReservationUiModel + +class ReservationListAdapter( + private val reservations: List, + private val onItemClick: OnItemClick +) : RecyclerView.Adapter() { + + fun interface OnItemClick { + fun onClick(reservation: ReservationUiModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReservationItemViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.reservation_item, parent, false) + return ReservationItemViewHolder(ReservationItemBinding.bind(view), onItemClick) + } + + override fun getItemCount(): Int { + return reservations.size + } + + override fun onBindViewHolder(holder: ReservationItemViewHolder, position: Int) { + holder.bind(reservations[position]) + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListFragment.kt b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListFragment.kt new file mode 100644 index 000000000..f7e95b6b6 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/reservationlist/ReservationListFragment.kt @@ -0,0 +1,22 @@ +package woowacourse.movie.view.moviemain.reservationlist + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import woowacourse.movie.R +import woowacourse.movie.data.ReservationMockRepository +import woowacourse.movie.view.ReservationCompletedActivity +import woowacourse.movie.view.mapper.toUiModel + +class ReservationListFragment : Fragment(R.layout.fragment_reservation_list) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val recyclerView = view.findViewById(R.id.recyclerview) + val reservations = ReservationMockRepository.findAll().map { it.toUiModel() } + recyclerView.adapter = ReservationListAdapter(reservations) { reservation -> + val intent = ReservationCompletedActivity.newIntent(requireContext(), reservation) + startActivity(intent) + } + } +} diff --git a/app/src/main/java/woowacourse/movie/view/moviemain/setting/SettingFragment.kt b/app/src/main/java/woowacourse/movie/view/moviemain/setting/SettingFragment.kt new file mode 100644 index 000000000..c77e3c615 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/moviemain/setting/SettingFragment.kt @@ -0,0 +1,78 @@ +package woowacourse.movie.view.moviemain.setting + +import android.Manifest +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.google.android.material.switchmaterial.SwitchMaterial +import woowacourse.movie.R +import woowacourse.movie.data.ReservationMockRepository +import woowacourse.movie.view.AlarmController +import woowacourse.movie.view.mapper.toUiModel + +class SettingFragment : Fragment(R.layout.fragment_setting) { + + private lateinit var alarmController: AlarmController + private lateinit var sharedPreferences: SharedPreferences + private lateinit var toggle: SwitchMaterial + + private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (!isGranted) { + Toast.makeText(requireContext(), "권한을 설정해야 알림을 받을 수 있습니다. 설정에서 알림을 켜주세요.", Toast.LENGTH_LONG).show() + toggle.isChecked = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + alarmController = AlarmController(requireContext()) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + toggle = view.findViewById(R.id.setting_toggle) + + val savedToggle = sharedPreferences.getBoolean(IS_ALARM_ON, false) + toggle.isChecked = savedToggle + + toggle.setOnCheckedChangeListener { _, isChecked -> + setToggleChangeListener(isChecked) + } + } + + private fun setToggleChangeListener(isChecked: Boolean) { + val editor: SharedPreferences.Editor = sharedPreferences.edit() + if (isChecked && requestNotificationPermission()) { + val reservations = ReservationMockRepository.findAll().map { it.toUiModel() } + alarmController.registerAlarms(reservations, ALARM_MINUTE_INTERVAL) + editor.putBoolean(IS_ALARM_ON, true).apply() + return + } + alarmController.cancelAlarms() + editor.putBoolean(IS_ALARM_ON, false).apply() + } + + private fun requestNotificationPermission(): Boolean { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + } + return true // android 12 version 이하는 notification 권한 필요 없기 때문 + } + + companion object { + const val IS_ALARM_ON = "IS_ALARM_ON" + const val ALARM_MINUTE_INTERVAL = 30L + } +} diff --git a/app/src/main/java/woowacourse/movie/view/seatselection/AlarmReceiver.kt b/app/src/main/java/woowacourse/movie/view/seatselection/AlarmReceiver.kt new file mode 100644 index 000000000..5fa265adb --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/seatselection/AlarmReceiver.kt @@ -0,0 +1,58 @@ +package woowacourse.movie.view.seatselection + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import woowacourse.movie.R +import woowacourse.movie.util.getParcelableCompat +import woowacourse.movie.view.ReservationCompletedActivity +import woowacourse.movie.view.model.ReservationUiModel + +class AlarmReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val reservation = intent.getParcelableCompat(RESERVATION) + val content = reservation?.let { + context.getString(R.string.screening_after_30_minutes, reservation.title) + } + val pendingIntent = reservation?.let { + ReservationCompletedActivity.getPendingIntent(context, reservation) + } + val notification = createNotification(context, content, pendingIntent) + notify(context, notification) + } + + private fun notify(context: Context, notification: Notification) { + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + return + } + } + + private fun createNotification(context: Context, content: String?, pendingIntent: PendingIntent?): Notification { + return NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(R.drawable.ic_movie) + setContentTitle(context.getString(R.string.reservation_noti)) + setContentText(content) + setContentIntent(pendingIntent) + setAutoCancel(true) + }.build() + } + + companion object { + const val NOTIFICATION_ID = 200 + const val CHANNEL_ID = "RESERVATION_CHANNEL" + const val RESERVATION = "RESERVATION" + } +} diff --git a/app/src/main/java/woowacourse/movie/view/seatselection/SeatSelectionActivity.kt b/app/src/main/java/woowacourse/movie/view/seatselection/SeatSelectionActivity.kt new file mode 100644 index 000000000..717c07399 --- /dev/null +++ b/app/src/main/java/woowacourse/movie/view/seatselection/SeatSelectionActivity.kt @@ -0,0 +1,213 @@ +package woowacourse.movie.view.seatselection + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.widget.Button +import android.widget.TableLayout +import android.widget.TableRow +import android.widget.Toolbar.LayoutParams +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.AppCompatButton +import androidx.core.view.children +import woowacourse.movie.R +import woowacourse.movie.data.ReservationMockRepository +import woowacourse.movie.databinding.ActivitySeatSelectionBinding +import woowacourse.movie.domain.ReservationAgency +import woowacourse.movie.domain.Seat +import woowacourse.movie.domain.repository.ReservationRepository +import woowacourse.movie.util.getParcelableCompat +import woowacourse.movie.view.ReservationCompletedActivity +import woowacourse.movie.view.mapper.toDomainModel +import woowacourse.movie.view.mapper.toUiModel +import woowacourse.movie.view.model.MovieListModel.MovieUiModel +import woowacourse.movie.view.model.ReservationOptions +import woowacourse.movie.view.model.SeatUiModel +import java.text.DecimalFormat + +class SeatSelectionActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySeatSelectionBinding + private val reservationRepository: ReservationRepository = ReservationMockRepository + private val reservationOptions by lazy { + intent.getParcelableCompat(RESERVATION_OPTIONS) + } + private lateinit var reservationAgency: ReservationAgency + private var selectedSeatCount = 0 + private var selectedSeats: List = emptyList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySeatSelectionBinding.inflate(layoutInflater) + setContentView(binding.root) + + initSeatButtons() + initReserveLayout() + initReservationAgency() + initConfirmReservationButton() + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + private fun initSeatButtons() { + for (row in Seat.MIN_ROW..Seat.MAX_ROW) { + val tableRow = TableRow(this).apply { + layoutParams = TableLayout.LayoutParams(0, 0, 1f) + } + for (col in Seat.MIN_COLUMN..Seat.MAX_COLUMN) { + val seat = Seat(col, row) + tableRow.addView(createSeat(this, seat.toUiModel())) + } + binding.seatTablelayout.addView(tableRow) + } + } + + private fun createSeat(context: Context, seatUi: SeatUiModel): AppCompatButton = + AppCompatButton(context).apply { + text = seatUi.name + setTextColor(getColor(seatUi.color)) + setOnClickListener { onSeatClick(this) } + background = + AppCompatResources.getDrawable(this@SeatSelectionActivity, R.drawable.selector_seat) + layoutParams = TableRow.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f) + } + + private fun onSeatClick(seat: Button) { + if (seat.isSelected) { + deselectSeat(seat) + return + } + selectSeat(seat) + } + + private fun deselectSeat(seat: Button) { + selectedSeatCount-- + seat.isSelected = false + binding.confirmReservationButton.isEnabled = false + binding.reservationFeeTextview.text = getString(R.string.reservation_fee_format).format( + DECIMAL_FORMAT.format(0), + ) + } + + private fun selectSeat(seat: Button) { + reservationOptions?.let { + if (selectedSeatCount < it.peopleCount) { + seat.isSelected = true + selectedSeatCount++ + if (selectedSeatCount == it.peopleCount) { + onSelectionComplete() + return + } + } + } + } + + private fun onSelectionComplete() { + val seats = binding.seatTablelayout.children + .filterIsInstance() + .flatMap { it.children } + .filterIsInstance