diff --git a/data/build.gradle b/data/build.gradle index efca3e0ba..01726b55e 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -61,4 +61,6 @@ dependencies { testImplementation libs.junit androidTestImplementation libs.ext.junit + + implementation(libs.androidx.datastore) } diff --git a/data/src/main/assets/department.json b/data/src/main/assets/department.json new file mode 100644 index 000000000..2ee4621fd --- /dev/null +++ b/data/src/main/assets/department.json @@ -0,0 +1,52 @@ +{ + "departments": [ + { + "id": 1, + "name": "컴퓨터공학부" + }, + { + "id": 2, + "name": "기계공학부" + }, + { + "id": 3, + "name": "전기전자통신공학부" + }, + { + "id": 4, + "name": "에너지신소재화학공학부" + }, + { + "id": 5, + "name": "산업경영학부" + }, + { + "id": 6, + "name": "메카트로닉스공학부" + }, + { + "id": 7, + "name": "디자인건축공학부" + }, + { + "id": 8, + "name": "고용서비스정책학과" + }, + { + "id": 9, + "name": "안전공학과" + }, + { + "id": 10, + "name": "교양학부" + }, + { + "id": 11, + "name": "HRD학과" + }, + { + "id": 12, + "name": "융합학과" + } + ] +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/api/TimetableApi.kt b/data/src/main/java/in/koreatech/koin/data/api/TimetableApi.kt new file mode 100644 index 000000000..d2b202f10 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/api/TimetableApi.kt @@ -0,0 +1,18 @@ +package `in`.koreatech.koin.data.api + +import `in`.koreatech.koin.data.constant.URLConstant.LECTURE +import `in`.koreatech.koin.data.constant.URLConstant.SEMESTERS +import `in`.koreatech.koin.data.response.timetable.LectureResponse +import `in`.koreatech.koin.data.response.timetable.SemesterResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface TimetableApi { + @GET(SEMESTERS) + suspend fun getSemesters(): List + + @GET(LECTURE) + suspend fun getLectures( + @Query("semester_date") semester: String + ): List +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/api/auth/TimetableAuthApi.kt b/data/src/main/java/in/koreatech/koin/data/api/auth/TimetableAuthApi.kt new file mode 100644 index 000000000..2707130fe --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/api/auth/TimetableAuthApi.kt @@ -0,0 +1,28 @@ +package `in`.koreatech.koin.data.api.auth + +import `in`.koreatech.koin.data.constant.URLConstant.TIMETABLE +import `in`.koreatech.koin.data.constant.URLConstant.TIMETABLES +import `in`.koreatech.koin.data.request.timetable.TimetablesRequest +import `in`.koreatech.koin.data.response.timetable.TimetablesResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface TimetableAuthApi { + @GET(TIMETABLES) + suspend fun getTimetables( + @Query("semester") semester: String, + ): TimetablesResponse + + @POST(TIMETABLES) + suspend fun postTimetables( + @Body timetables: TimetablesRequest + ) + + @DELETE(TIMETABLE) + suspend fun deleteTimetables( + @Query("id") id: Int + ) +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/di/datastore/DataStoreModule.kt b/data/src/main/java/in/koreatech/koin/data/di/datastore/DataStoreModule.kt new file mode 100644 index 000000000..640de8945 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/di/datastore/DataStoreModule.kt @@ -0,0 +1,36 @@ +package `in`.koreatech.koin.data.di.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +private const val KOIN_PREFERENCES = "koin_preferences" + +@InstallIn(SingletonComponent::class) +@Module +object DataStoreModule { + @Singleton + @Provides + fun providesPreferencesDataStore(@ApplicationContext appContext: Context): DataStore { + return PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { appContext.preferencesDataStoreFile(KOIN_PREFERENCES) } + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/di/network/NoAuthNetworkModule.kt b/data/src/main/java/in/koreatech/koin/data/di/network/NoAuthNetworkModule.kt index 5458516ac..cca5576c8 100644 --- a/data/src/main/java/in/koreatech/koin/data/di/network/NoAuthNetworkModule.kt +++ b/data/src/main/java/in/koreatech/koin/data/di/network/NoAuthNetworkModule.kt @@ -111,4 +111,12 @@ object NoAuthNetworkModule { ): LandApi { return retrofit.create(LandApi::class.java) } + + @Provides + @Singleton + fun providesTimetableApi( + @NoAuth retrofit: Retrofit + ): TimetableApi { + return retrofit.create(TimetableApi::class.java) + } } \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/di/repository/RepositoryModule.kt b/data/src/main/java/in/koreatech/koin/data/di/repository/RepositoryModule.kt index 9ea5f1982..2e2bc6b3f 100644 --- a/data/src/main/java/in/koreatech/koin/data/di/repository/RepositoryModule.kt +++ b/data/src/main/java/in/koreatech/koin/data/di/repository/RepositoryModule.kt @@ -6,10 +6,58 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import `in`.koreatech.koin.data.repository.* -import `in`.koreatech.koin.data.source.local.* -import `in`.koreatech.koin.data.source.remote.* -import `in`.koreatech.koin.domain.repository.* +import `in`.koreatech.koin.data.repository.BusRepositoryImpl +import `in`.koreatech.koin.data.repository.DeptRepositoryImpl +import `in`.koreatech.koin.data.repository.DiningRepositoryImpl +import `in`.koreatech.koin.data.repository.LandRepositoryImpl +import `in`.koreatech.koin.data.repository.NotificationRepositoryImpl +import `in`.koreatech.koin.data.repository.OwnerChangePasswordRepositoryImpl +import `in`.koreatech.koin.data.repository.OwnerRegisterRepositoryImpl +import `in`.koreatech.koin.data.repository.OwnerSignupRepositoryImpl +import `in`.koreatech.koin.data.repository.OwnerVerificationCodeRepositoryImpl +import `in`.koreatech.koin.data.repository.PreSignedUrlRepositoryImpl +import `in`.koreatech.koin.data.repository.SignupRepositoryImpl +import `in`.koreatech.koin.data.repository.StoreRepositoryImpl +import `in`.koreatech.koin.data.repository.TimetableRepositoryImpl +import `in`.koreatech.koin.data.repository.TokenRepositoryImpl +import `in`.koreatech.koin.data.repository.UploadUrlRepositoryImpl +import `in`.koreatech.koin.data.repository.UserRepositoryImpl +import `in`.koreatech.koin.data.repository.VersionRepositoryImpl +import `in`.koreatech.koin.data.source.local.BusLocalDataSource +import `in`.koreatech.koin.data.source.local.DeptLocalDataSource +import `in`.koreatech.koin.data.source.local.SignupTermsLocalDataSource +import `in`.koreatech.koin.data.source.local.TimetableLocalDataSource +import `in`.koreatech.koin.data.source.local.TokenLocalDataSource +import `in`.koreatech.koin.data.source.local.VersionLocalDataSource +import `in`.koreatech.koin.data.source.remote.BusRemoteDataSource +import `in`.koreatech.koin.data.source.remote.DeptRemoteDataSource +import `in`.koreatech.koin.data.source.remote.DiningRemoteDataSource +import `in`.koreatech.koin.data.source.remote.LandRemoteDataSource +import `in`.koreatech.koin.data.source.remote.NotificationRemoteDataSource +import `in`.koreatech.koin.data.source.remote.OwnerRemoteDataSource +import `in`.koreatech.koin.data.source.remote.PreSignedUrlRemoteDataSource +import `in`.koreatech.koin.data.source.remote.StoreRemoteDataSource +import `in`.koreatech.koin.data.source.remote.TimetableRemoteDataSource +import `in`.koreatech.koin.data.source.remote.UploadUrlRemoteDataSource +import `in`.koreatech.koin.data.source.remote.UserRemoteDataSource +import `in`.koreatech.koin.data.source.remote.VersionRemoteDataSource +import `in`.koreatech.koin.domain.repository.BusRepository +import `in`.koreatech.koin.domain.repository.DeptRepository +import `in`.koreatech.koin.domain.repository.DiningRepository +import `in`.koreatech.koin.domain.repository.LandRepository +import `in`.koreatech.koin.domain.repository.NotificationRepository +import `in`.koreatech.koin.domain.repository.OwnerChangePasswordRepository +import `in`.koreatech.koin.domain.repository.OwnerRegisterRepository +import `in`.koreatech.koin.domain.repository.OwnerSignupRepository +import `in`.koreatech.koin.domain.repository.OwnerVerificationCodeRepository +import `in`.koreatech.koin.domain.repository.PreSignedUrlRepository +import `in`.koreatech.koin.domain.repository.SignupRepository +import `in`.koreatech.koin.domain.repository.StoreRepository +import `in`.koreatech.koin.domain.repository.TimetableRepository +import `in`.koreatech.koin.domain.repository.TokenRepository +import `in`.koreatech.koin.domain.repository.UploadUrlRepository +import `in`.koreatech.koin.domain.repository.UserRepository +import `in`.koreatech.koin.domain.repository.VersionRepository import javax.inject.Singleton @Module @@ -154,4 +202,14 @@ object RepositoryModule { ): OwnerChangePasswordRepository { return OwnerChangePasswordRepositoryImpl(ownerRemoteDataSource) } + + @Provides + @Singleton + fun providesTimetableRepository( + timetableRemoteDataSource: TimetableRemoteDataSource, + timetableLocalDataSource: TimetableLocalDataSource, + tokenLocalDataSource: TokenLocalDataSource + ): TimetableRepository { + return TimetableRepositoryImpl(timetableRemoteDataSource, timetableLocalDataSource, tokenLocalDataSource) + } } \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/di/source/LocalDataSourceModule.kt b/data/src/main/java/in/koreatech/koin/data/di/source/LocalDataSourceModule.kt index a99b061dc..3e28528a3 100644 --- a/data/src/main/java/in/koreatech/koin/data/di/source/LocalDataSourceModule.kt +++ b/data/src/main/java/in/koreatech/koin/data/di/source/LocalDataSourceModule.kt @@ -1,6 +1,8 @@ package `in`.koreatech.koin.data.di.source import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -10,6 +12,7 @@ import `in`.koreatech.koin.core.qualifier.IoDispatcher import `in`.koreatech.koin.data.source.local.BusLocalDataSource import `in`.koreatech.koin.data.source.local.DeptLocalDataSource import `in`.koreatech.koin.data.source.local.SignupTermsLocalDataSource +import `in`.koreatech.koin.data.source.local.TimetableLocalDataSource import `in`.koreatech.koin.data.source.local.TokenLocalDataSource import `in`.koreatech.koin.data.source.local.VersionLocalDataSource import kotlinx.coroutines.CoroutineDispatcher @@ -58,4 +61,13 @@ object LocalDataSourceModule { ) : DeptLocalDataSource { return DeptLocalDataSource(applicationContext) } + + @Provides + @Singleton + fun providesTimetableLocalDataSource( + @ApplicationContext applicationContext: Context, + dataStore: DataStore + ): TimetableLocalDataSource { + return TimetableLocalDataSource(applicationContext, dataStore) + } } \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/di/source/RemoteDataSourceModule.kt b/data/src/main/java/in/koreatech/koin/data/di/source/RemoteDataSourceModule.kt index 5090bd351..6e95eb796 100644 --- a/data/src/main/java/in/koreatech/koin/data/di/source/RemoteDataSourceModule.kt +++ b/data/src/main/java/in/koreatech/koin/data/di/source/RemoteDataSourceModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import `in`.koreatech.koin.data.api.* +import `in`.koreatech.koin.data.api.auth.TimetableAuthApi import `in`.koreatech.koin.data.api.auth.UserAuthApi import `in`.koreatech.koin.data.source.remote.* import javax.inject.Singleton @@ -100,4 +101,13 @@ object RemoteDataSourceModule { ): PreSignedUrlRemoteDataSource { return PreSignedUrlRemoteDataSource(preSignedUrlApi) } + + @Provides + @Singleton + fun providesTimetableRemoteDataSource( + timetableApi: TimetableApi, + timetableAuthApi: TimetableAuthApi + ): TimetableRemoteDataSource { + return TimetableRemoteDataSource(timetableApi, timetableAuthApi) + } } \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/mapper/TimetableMapper.kt b/data/src/main/java/in/koreatech/koin/data/mapper/TimetableMapper.kt new file mode 100644 index 000000000..5564f7686 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/mapper/TimetableMapper.kt @@ -0,0 +1,75 @@ +package `in`.koreatech.koin.data.mapper + +import com.google.gson.annotations.SerializedName +import `in`.koreatech.koin.data.request.timetable.TimetablesLectureRequest +import `in`.koreatech.koin.data.request.timetable.TimetablesRequest +import `in`.koreatech.koin.data.response.timetable.DepartmentResponse +import `in`.koreatech.koin.data.response.timetable.LectureResponse +import `in`.koreatech.koin.data.response.timetable.SemesterResponse +import `in`.koreatech.koin.data.response.timetable.TimetablesLectureResponse +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester + +fun SemesterResponse.toSemester() = Semester( + id = this.id, + semester = this.semester +) + +fun LectureResponse.toLecture() = Lecture( + id = this.id ?: 0, + code = this.code ?: "", + name = this.name ?: "", + grades = this.grades ?: "", + lectureClass = this.lectureClass ?: "", + regularNumber = this.regularNumber ?: "", + department = this.department ?: "", + target = this.target ?: "", + professor = this.professor ?: "", + isEnglish = this.isEnglish ?: "", + designScore = this.designScore ?: "", + isElearning = this.isElearning ?: "", + classTime = this.classTime, +) + +fun TimetablesLectureResponse.toLecture() = Lecture( + id = this.id ?: 0, + code = this.code ?: "", + name = this.name ?: "", + grades = this.grades ?: "", + lectureClass = this.lectureClass ?: "", + regularNumber = this.regularNumber ?: "", + department = this.department ?: "", + target = this.target ?: "", + professor = this.professor ?: "", + isEnglish = "", + designScore = this.designScore ?: "", + isElearning = "", + classTime = this.classTime.orEmpty(), +) + +fun DepartmentResponse.toDepartment() = Department( + id = this.id, + name = this.name +) + +fun List.toTimetablesRequest(semester: String) = TimetablesRequest( + timetable = this.map { it.toTimetablesLectureResponse() }, + semester = semester +) + +fun Lecture.toTimetablesLectureResponse() = TimetablesLectureRequest( + id = this.id, + code = this.code, + name = this.name, + grades = this.grades, + lectureClass = this.lectureClass, + regularNumber = this.regularNumber, + department = this.department, + target = this.target, + professor = this.professor, + designScore = this.designScore, + classPlace = "", + memo = "", + classTime = this.classTime +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/repository/TimetableRepositoryImpl.kt b/data/src/main/java/in/koreatech/koin/data/repository/TimetableRepositoryImpl.kt new file mode 100644 index 000000000..530df8ea5 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/repository/TimetableRepositoryImpl.kt @@ -0,0 +1,76 @@ +package `in`.koreatech.koin.data.repository + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import `in`.koreatech.koin.data.mapper.toDepartment +import `in`.koreatech.koin.data.mapper.toLecture +import `in`.koreatech.koin.data.mapper.toSemester +import `in`.koreatech.koin.data.mapper.toTimetablesRequest +import `in`.koreatech.koin.data.source.local.TimetableLocalDataSource +import `in`.koreatech.koin.data.source.local.TokenLocalDataSource +import `in`.koreatech.koin.data.source.remote.TimetableRemoteDataSource +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.domain.repository.TimetableRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class TimetableRepositoryImpl @Inject constructor( + private val timetableRemoteDataSource: TimetableRemoteDataSource, + private val timetableLocalDataSource: TimetableLocalDataSource, + private val tokenLocalDataSource: TokenLocalDataSource +) : TimetableRepository { + override suspend fun getTimetables(key: String, isAnonymous: Boolean): List = + try { + if (tokenLocalDataSource.getAccessToken().isNullOrEmpty() && isAnonymous) { + val lectureString = timetableLocalDataSource.getString(key).first() + val lectureType = object : TypeToken>() {}.type + val gson = Gson() + val updateLectures = + gson.fromJson>(lectureString, lectureType).orEmpty() + updateLectures + } else { + timetableRemoteDataSource.loadTimetables(key).timetables?.map { it.toLecture() }.orEmpty() + } + } catch (e: Exception) { + emptyList() + } + + override suspend fun updateTimetables(key: String, isAnonymous: Boolean, value: List) { + try { + if (isAnonymous) { + timetableLocalDataSource.putString(key, Gson().toJson(value)) + } else { + timetableRemoteDataSource.updateTimetables(value.toTimetablesRequest(key)) + } + } catch (e: Exception) { + e.message + } + } + + override suspend fun updateCurrentSemester(semester: String) { + try { + timetableLocalDataSource.putSemester(semester) + } catch (e: Exception) { + e.message + } + } + + override suspend fun removeTimetables(id: Int) { + try { + timetableRemoteDataSource.deleteTimetables(id) + } catch (e: Exception) { + e.message + } + } + + override suspend fun loadSemesters(): List = + timetableRemoteDataSource.loadSemesters().map { it.toSemester() } + + override suspend fun loadLectures(semester: String): List = + timetableRemoteDataSource.loadLectures(semester).map { it.toLecture() } + + override suspend fun loadDepartments(): List = + timetableLocalDataSource.loadDepartments()?.map { it.toDepartment() }.orEmpty() +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesLectureRequest.kt b/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesLectureRequest.kt new file mode 100644 index 000000000..20f17c81e --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesLectureRequest.kt @@ -0,0 +1,32 @@ +package `in`.koreatech.koin.data.request.timetable + +import com.google.gson.annotations.SerializedName + +data class TimetablesLectureRequest( + @SerializedName("id") + var id: Int? = 0, + @SerializedName("code") // 과목코드 : BSM314 + val code: String? = "", + @SerializedName("class_title") // 강의명 : 물리적 사고 + val name: String? = "", + @SerializedName("grades") // 학년 : 3 + val grades: String? = "", + @SerializedName("lecture_class") // 분반 : 01 + val lectureClass: String? = "", + @SerializedName("regular_number") // 수강인원 : 0~40 + val regularNumber: String? = "", + @SerializedName("department") // 학부 : 교양학부 + val department: String? = "", + @SerializedName("target") // 대상 : 기공1 + val target: String? = "", + @SerializedName("professor") // 교수 : 이미리 + val professor: String? = "", + @SerializedName("design_score") // 설계학점 : 0 + val designScore: String? = "", + @SerializedName("class_place") + val classPlace: String? = "", + @SerializedName("memo") // 설계학점 : 0 + val memo: String? = "", + @SerializedName("class_time") // 강의시간 : 0~429 + val classTime: List? = emptyList(), +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesRequest.kt b/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesRequest.kt new file mode 100644 index 000000000..013ba6ebb --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/request/timetable/TimetablesRequest.kt @@ -0,0 +1,10 @@ +package `in`.koreatech.koin.data.request.timetable + +import com.google.gson.annotations.SerializedName + +data class TimetablesRequest( + @SerializedName("timetable") + val timetable: List, + @SerializedName("semester") + val semester: String, +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/response/timetable/DepartmentResponse.kt b/data/src/main/java/in/koreatech/koin/data/response/timetable/DepartmentResponse.kt new file mode 100644 index 000000000..bb0587dd3 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/response/timetable/DepartmentResponse.kt @@ -0,0 +1,15 @@ +package `in`.koreatech.koin.data.response.timetable + +import com.google.gson.annotations.SerializedName + +data class DepartmentsResponse( + @SerializedName("departments") + val departments: List, +) + +data class DepartmentResponse( + @SerializedName("id") + val id: Int, + @SerializedName("name") + val name: String, +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/response/timetable/LectureResponse.kt b/data/src/main/java/in/koreatech/koin/data/response/timetable/LectureResponse.kt new file mode 100644 index 000000000..4ffbca52f --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/response/timetable/LectureResponse.kt @@ -0,0 +1,31 @@ +package `in`.koreatech.koin.data.response.timetable + +import com.google.gson.annotations.SerializedName + +data class LectureResponse( + var id: Int? = 0, + @SerializedName("code") // 과목코드 : BSM314 + val code: String? = "", + @SerializedName("name") // 강의명 : 물리적 사고 + val name: String? = "", + @SerializedName("grades") // 학년 : 3 + val grades: String? = "", + @SerializedName("lecture_class") // 분반 : 01 + val lectureClass: String? = "", + @SerializedName("regular_number") // 수강인원 : 0~40 + val regularNumber: String? = "", + @SerializedName("department") // 학부 : 교양학부 + val department: String? = "", + @SerializedName("target") // 대상 : 기공1 + val target: String? = "", + @SerializedName("professor") // 교수 : 이미리 + val professor: String? = "", + @SerializedName("is_english") // 영어수업인지 : N/Y + val isEnglish: String? = "", + @SerializedName("design_score") // 설계학점 : 0 + val designScore: String? = "", + @SerializedName("is_elearning") // 이러닝인지 : N/Y + val isElearning: String? = "", + @SerializedName("class_time") // 강의시간 : 0~429 + val classTime: List = emptyList(), +) diff --git a/data/src/main/java/in/koreatech/koin/data/response/timetable/SemesterResponse.kt b/data/src/main/java/in/koreatech/koin/data/response/timetable/SemesterResponse.kt new file mode 100644 index 000000000..bcebd5e99 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/response/timetable/SemesterResponse.kt @@ -0,0 +1,10 @@ +package `in`.koreatech.koin.data.response.timetable + +import com.google.gson.annotations.SerializedName + +data class SemesterResponse( + @SerializedName("id") + val id: Int, + @SerializedName("semester") + val semester: String, +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesLectureResponse.kt b/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesLectureResponse.kt new file mode 100644 index 000000000..a7597fd06 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesLectureResponse.kt @@ -0,0 +1,32 @@ +package `in`.koreatech.koin.data.response.timetable + +import com.google.gson.annotations.SerializedName + +data class TimetablesLectureResponse( + @SerializedName("id") + var id: Int? = 0, + @SerializedName("code") // 과목코드 : BSM314 + val code: String? = "", + @SerializedName("class_title") // 강의명 : 물리적 사고 + val name: String? = "", + @SerializedName("grades") // 학년 : 3 + val grades: String? = "", + @SerializedName("lecture_class") // 분반 : 01 + val lectureClass: String? = "", + @SerializedName("regular_number") // 수강인원 : 0~40 + val regularNumber: String? = "", + @SerializedName("department") // 학부 : 교양학부 + val department: String? = "", + @SerializedName("target") // 대상 : 기공1 + val target: String? = "", + @SerializedName("professor") // 교수 : 이미리 + val professor: String? = "", + @SerializedName("design_score") // 설계학점 : 0 + val designScore: String? = "", + @SerializedName("class_place") + val classPlace: String? = "", + @SerializedName("memo") // 설계학점 : 0 + val memo: String? = "", + @SerializedName("class_time") // 강의시간 : 0~429 + val classTime: List? = emptyList(), +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesResponse.kt b/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesResponse.kt new file mode 100644 index 000000000..605425507 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/response/timetable/TimetablesResponse.kt @@ -0,0 +1,14 @@ +package `in`.koreatech.koin.data.response.timetable + +import com.google.gson.annotations.SerializedName + +data class TimetablesResponse( + @SerializedName("semester") + val semester: String? = "", + @SerializedName("timetable") + val timetables: List? = emptyList(), + @SerializedName("grades") + val grades: Int? = 0, + @SerializedName("total_grades") + val totalGrades: Int? = 0, +) \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/source/local/TimetableLocalDataSource.kt b/data/src/main/java/in/koreatech/koin/data/source/local/TimetableLocalDataSource.kt new file mode 100644 index 000000000..944039d92 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/source/local/TimetableLocalDataSource.kt @@ -0,0 +1,39 @@ +package `in`.koreatech.koin.data.source.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dagger.hilt.android.qualifiers.ApplicationContext +import `in`.koreatech.koin.data.response.timetable.DepartmentResponse +import `in`.koreatech.koin.data.response.timetable.DepartmentsResponse +import `in`.koreatech.koin.data.util.readData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TimetableLocalDataSource @Inject constructor( + @ApplicationContext private val context: Context, + private val dataStore: DataStore +) { + fun getString(key: String): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)] ?: "" + } + + suspend fun putString(key: String, value: String) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(key)] = value + } + } + + suspend fun putSemester(value: String) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey("semester")] = value + } + } + + fun loadDepartments(): List? = + context.readData("department.json")?.departments +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/source/remote/TimetableRemoteDataSource.kt b/data/src/main/java/in/koreatech/koin/data/source/remote/TimetableRemoteDataSource.kt new file mode 100644 index 000000000..c9a1122fa --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/source/remote/TimetableRemoteDataSource.kt @@ -0,0 +1,31 @@ +package `in`.koreatech.koin.data.source.remote + +import `in`.koreatech.koin.data.api.TimetableApi +import `in`.koreatech.koin.data.api.auth.TimetableAuthApi +import `in`.koreatech.koin.data.request.timetable.TimetablesRequest +import `in`.koreatech.koin.data.response.timetable.LectureResponse +import `in`.koreatech.koin.data.response.timetable.SemesterResponse +import `in`.koreatech.koin.data.response.timetable.TimetablesResponse +import javax.inject.Inject + +class TimetableRemoteDataSource @Inject constructor( + private val timetableApi: TimetableApi, + private val timetableAuthApi: TimetableAuthApi, +) { + suspend fun loadTimetables(semester: String): TimetablesResponse = + timetableAuthApi.getTimetables(semester) + + suspend fun loadSemesters(): List = + timetableApi.getSemesters() + + suspend fun loadLectures(semester: String): List = + timetableApi.getLectures(semester) + + suspend fun updateTimetables(timetablesRequest: TimetablesRequest) { + timetableAuthApi.postTimetables(timetablesRequest) + } + + suspend fun deleteTimetables(id: Int) { + timetableAuthApi.deleteTimetables(id) + } +} \ No newline at end of file diff --git a/data/src/main/java/in/koreatech/koin/data/util/AssetUtils.kt b/data/src/main/java/in/koreatech/koin/data/util/AssetUtils.kt new file mode 100644 index 000000000..9315d60d9 --- /dev/null +++ b/data/src/main/java/in/koreatech/koin/data/util/AssetUtils.kt @@ -0,0 +1,23 @@ +package `in`.koreatech.koin.data.util + +import android.content.Context +import com.google.gson.Gson +import java.io.IOException + +inline fun Context.readData(assetName: String): T? { + return try { + val inputStream = this.resources.assets.open(assetName) + val buffer = ByteArray(inputStream.available()) + inputStream.read(buffer) + inputStream.close() + + val gson = Gson() + gson.fromJson(String(buffer), T::class.java) + } catch (e: IOException) { + e.message + null + } catch (e: Exception) { + e.message + null + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Department.kt b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Department.kt new file mode 100644 index 000000000..eb7ff1836 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Department.kt @@ -0,0 +1,6 @@ +package `in`.koreatech.koin.domain.model.timetable + +data class Department( + val id: Int = 0, + val name: String = "" +) \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Lecture.kt b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Lecture.kt new file mode 100644 index 000000000..c76d5129a --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Lecture.kt @@ -0,0 +1,108 @@ +package `in`.koreatech.koin.domain.model.timetable + +import `in`.koreatech.koin.domain.util.ext.toDepartmentString +import java.time.DayOfWeek +import java.time.LocalTime + +data class Lecture( + var id: Int = 0, + val code: String = "", + val name: String = "", + val grades: String = "", + val lectureClass: String = "", + val regularNumber: String = "", + val department: String = "", + val target: String = "", + val professor: String = "", + val isEnglish: String = "", + val designScore: String = "", + val isElearning: String = "", + val classTime: List = emptyList(), +) { + fun findDayOfWeekAndTime(): Map> { + return classTime.groupBy { it / 100 } + .mapValues { entry -> + /** + * @input : [0,1,100,101] + */ + entry.value.sorted().map { value -> + val timeIndex = if (entry.key == 0) value else value % (entry.key * 100) + LocalTime.of(9 + timeIndex / 2, (timeIndex % 2) * 30) + } + /** + * @output : [09:00, 09:30], [09:00, 09:30] + */ + } + .mapKeys { + /** + * @input : {0=[09:00, 09:30], 1=[09:00, 09:30]} + */ + when (it.key) { + 0 -> DayOfWeek.MONDAY + 1 -> DayOfWeek.TUESDAY + 2 -> DayOfWeek.WEDNESDAY + 3 -> DayOfWeek.THURSDAY + 4 -> DayOfWeek.FRIDAY + else -> null + } + /** + * @output : {MONDAY=[09:00, 09:30], TUESDAY=[09:00, 09:30]} + */ + } + } + + fun formatDescription(): String { + val description = if (target.isEmpty()) { + "" + } else { + target + }.let { + if (code.isNotEmpty()) "$it / $code" + else it + }.let { + if (grades.isNotEmpty()) "$it / ${grades}학점" + else it + }.let { + if (professor.isNotEmpty()) "$it / $professor" + else "$it / 미정(교수)" + }.let { + if (regularNumber.isNotEmpty()) "$it / ${regularNumber}명" + else "$it / 미정(인원)" + } + + return description + } + + fun doesMatchSearchQuery(query: String): Boolean { + val matchingCombinations = listOf( + "$name", + "${name?.first()}" + ) + + return matchingCombinations.any { + it.contains(query, ignoreCase = true) + } + } + + fun doesMatchDepartmentSearchQuery(departments: List): Boolean { + val matchingCombination = department.toDepartmentString() + + return departments.any { + it.contains(matchingCombination, ignoreCase = true) + } + } + + /** + * 시간표 강의 중복 + * @example : 강의 시간 겹침 + 완전 준복 + */ + fun duplicate(lectures: List): Boolean { + var flag = false + classTime.forEach { time -> + if (lectures.filter { it.classTime.contains(time) }.isNotEmpty()) { + flag = true + } + } + return flag + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Semester.kt b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Semester.kt new file mode 100644 index 000000000..586679f11 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/model/timetable/Semester.kt @@ -0,0 +1,12 @@ +package `in`.koreatech.koin.domain.model.timetable + +data class Semester( + val id: Int = 0, + val semester: String = "", +) { + /** + * @sample + * 20242 : 2024년 2학기 + */ + fun format() = "${semester.take(4)}년 ${semester.drop(4)}학기" +} diff --git a/domain/src/main/java/in/koreatech/koin/domain/repository/TimetableRepository.kt b/domain/src/main/java/in/koreatech/koin/domain/repository/TimetableRepository.kt new file mode 100644 index 000000000..1b6acd093 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/repository/TimetableRepository.kt @@ -0,0 +1,16 @@ +package `in`.koreatech.koin.domain.repository + +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester +import kotlinx.coroutines.flow.Flow + +interface TimetableRepository { + suspend fun getTimetables(key: String, isAnonymous: Boolean): List + suspend fun updateTimetables(key: String, isAnonymous: Boolean, value: List) + suspend fun updateCurrentSemester(semester: String) + suspend fun removeTimetables(id: Int) + suspend fun loadSemesters(): List + suspend fun loadDepartments(): List + suspend fun loadLectures(semester: String): List +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetDepartmentsUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetDepartmentsUseCase.kt new file mode 100644 index 000000000..6a6448a43 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetDepartmentsUseCase.kt @@ -0,0 +1,16 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class GetDepartmentsUseCase @Inject constructor( + private val timetableRepository: TimetableRepository, +) { + suspend operator fun invoke(): List = + try { + timetableRepository.loadDepartments() + } catch (e: Exception) { + emptyList() + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetLecturesUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetLecturesUseCase.kt new file mode 100644 index 000000000..7af54ad2d --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetLecturesUseCase.kt @@ -0,0 +1,16 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class GetLecturesUseCase @Inject constructor( + private val timetableRepository: TimetableRepository, +) { + suspend operator fun invoke(semester: String): List = + try { + timetableRepository.loadLectures(semester) + } catch (e: Exception) { + emptyList() + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetSemesterUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetSemesterUseCase.kt new file mode 100644 index 000000000..ae96e155d --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetSemesterUseCase.kt @@ -0,0 +1,16 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class GetSemesterUseCase @Inject constructor( + private val timetableRepository: TimetableRepository, +) { + suspend operator fun invoke(): List = + try { + timetableRepository.loadSemesters() + } catch (e: Exception) { + emptyList() + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetTimetablesUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetTimetablesUseCase.kt new file mode 100644 index 000000000..65b4b9528 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/GetTimetablesUseCase.kt @@ -0,0 +1,14 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class GetTimetablesUseCase @Inject constructor( + private val timetableRepository: TimetableRepository, +) { + suspend operator fun invoke( + semester: String, + isAnonymous: Boolean, + ): List = timetableRepository.getTimetables(semester, isAnonymous) +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/RemoveTimetablesUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/RemoveTimetablesUseCase.kt new file mode 100644 index 000000000..ab322b140 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/RemoveTimetablesUseCase.kt @@ -0,0 +1,10 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class RemoveTimetablesUseCase @Inject constructor( + private val timetableRepository: TimetableRepository, +) { + suspend operator fun invoke(id: Int) = timetableRepository.removeTimetables(id) +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateSemesterUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateSemesterUseCase.kt new file mode 100644 index 000000000..b2072f94e --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateSemesterUseCase.kt @@ -0,0 +1,12 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class UpdateSemesterUseCase @Inject constructor( + private val timetableRepository: TimetableRepository +) { + suspend operator fun invoke(semester: String) { + timetableRepository.updateCurrentSemester(semester) + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateTimetablesUseCase.kt b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateTimetablesUseCase.kt new file mode 100644 index 000000000..b058059a5 --- /dev/null +++ b/domain/src/main/java/in/koreatech/koin/domain/usecase/timetable/UpdateTimetablesUseCase.kt @@ -0,0 +1,17 @@ +package `in`.koreatech.koin.domain.usecase.timetable + +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.repository.TimetableRepository +import javax.inject.Inject + +class UpdateTimetablesUseCase @Inject constructor( + private val timetableRepository: TimetableRepository +) { + suspend operator fun invoke( + semester: String, + isAnonymous: Boolean, + lectures: List, + ) { + timetableRepository.updateTimetables(semester, isAnonymous, lectures) + } +} \ No newline at end of file diff --git a/domain/src/main/java/in/koreatech/koin/domain/util/ext/StringExtensions.kt b/domain/src/main/java/in/koreatech/koin/domain/util/ext/StringExtensions.kt index 6e26cc07f..ffd28ed16 100644 --- a/domain/src/main/java/in/koreatech/koin/domain/util/ext/StringExtensions.kt +++ b/domain/src/main/java/in/koreatech/koin/domain/util/ext/StringExtensions.kt @@ -23,3 +23,18 @@ fun String.formatPhoneNumber(): String = fun String.formatBusinessNumber(): String = this.replace(Regex("(\\d{3})(\\d{2})(\\d{5})"), "$1-$2-$3") +fun String.toDepartmentString(): String = when(this) { + "HRD학과" -> "HRD" + "고용서비스정책학과" -> "고용서비스" + "교양학부" -> "교양" + "디자인ㆍ건축공학부" -> "디자인" + "메카트로닉스공학부" -> "메카트로닉스" + "산업경영학부" -> "산업경영" + "에너지신소재화학공학부" -> "에너지신소재" + "융합학과" -> "융합" + "전기ㆍ전자ㆍ통신공학부" -> "전기" + "컴퓨터공학부" -> "컴퓨터" + "안전공학과" -> "안전" + "기계공학부" -> "기계" + else -> "" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e009124d..d1483fafa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,8 @@ napier = "2.6.1" composeNumberPickerVersion = "1.0.3" powerSpinner = "1.2.7" firebaseCrashlyticsBuildtoolsVersion = "2.9.9" -composeNumberPickerVersion = "1.0.3" +datastore = "1.1.1" +glance = "1.0.0" [libraries] androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtxVersion" } @@ -63,9 +64,11 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtxVersion" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtxVersion" } +androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelKtxVersion" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerviewVersion" } androidx-runner = { module = "androidx.test:runner", version.ref = "runnerVersion" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoVersion" } +androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" } napier = {module = "io.github.aakira:napier", version.ref="napier"} @@ -147,7 +150,9 @@ coil-svg = {module = "io.coil-kt:coil-svg", version.ref ="coilVersion"} compose-numberPicker = {module = "com.chargemap.compose:numberpicker", version.ref = "composeNumberPickerVersion"} powerSpinner = {module = "com.github.skydoves:powerspinner", version.ref = "powerSpinner"} -compose-numberPicker = {module = "com.chargemap.compose:numberpicker", version.ref = "composeNumberPickerVersion"} + +glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +glance-material = { group = "androidx.glance", name = "glance-material", version.ref = "glance" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradleVersion" } diff --git a/koin/build.gradle b/koin/build.gradle index bd5f7cee0..414f7c798 100644 --- a/koin/build.gradle +++ b/koin/build.gradle @@ -1,12 +1,11 @@ plugins { - id 'com.android.application' + alias(libs.plugins.koin.compose) + alias(libs.plugins.koin.orbit) id 'kotlin-android' - id 'kotlin-kapt' id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' id 'com.google.firebase.appdistribution' id 'com.google.dagger.hilt.android' - id 'org.jetbrains.kotlin.android' } def applicationName = 'koin' @@ -131,4 +130,9 @@ dependencies { implementation(libs.napier) implementation(libs.powerSpinner) + + implementation(libs.coil.compose) + implementation(libs.androidx.lifecycle.compose) + implementation(libs.glance.appwidget) + implementation(libs.glance.material) } diff --git a/koin/src/main/AndroidManifest.xml b/koin/src/main/AndroidManifest.xml index 2abd51936..27ee939c8 100644 --- a/koin/src/main/AndroidManifest.xml +++ b/koin/src/main/AndroidManifest.xml @@ -46,6 +46,9 @@ android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/black" /> + - + - + - + android:resource="@xml/timetablev2_app_widget_info"/> + + + + + + + + + + + + + + Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) DarkColorScheme else LightColorScheme + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colors = colorScheme, + content = content + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/di/network/AuthNetworkModule.kt b/koin/src/main/java/in/koreatech/koin/di/network/AuthNetworkModule.kt index def9c17e1..1045a318e 100644 --- a/koin/src/main/java/in/koreatech/koin/di/network/AuthNetworkModule.kt +++ b/koin/src/main/java/in/koreatech/koin/di/network/AuthNetworkModule.kt @@ -14,6 +14,7 @@ import `in`.koreatech.koin.core.qualifier.ServerUrl import `in`.koreatech.koin.data.api.PreSignedUrlApi import `in`.koreatech.koin.data.api.UploadUrlApi import `in`.koreatech.koin.data.api.UserApi +import `in`.koreatech.koin.data.api.auth.TimetableAuthApi import `in`.koreatech.koin.data.api.auth.UserAuthApi import `in`.koreatech.koin.data.source.local.TokenLocalDataSource import `in`.koreatech.koin.domain.usecase.user.DeleteUserRefreshTokenUseCase @@ -101,6 +102,14 @@ object AuthNetworkModule { ) : UserAuthApi { return retrofit.create(UserAuthApi::class.java) } + + @Provides + @Singleton + fun providesTimetableAuthApi( + @Auth retrofit: Retrofit + ): TimetableAuthApi { + return retrofit.create(TimetableAuthApi::class.java) + } } @Module diff --git a/koin/src/main/java/in/koreatech/koin/model/timetable/TimeBlock.kt b/koin/src/main/java/in/koreatech/koin/model/timetable/TimeBlock.kt new file mode 100644 index 000000000..843e02280 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/model/timetable/TimeBlock.kt @@ -0,0 +1,15 @@ +package `in`.koreatech.koin.model.timetable + +import androidx.compose.ui.graphics.Color +import java.time.LocalTime + +data class TimeBlock( + val title: String = "", + val start: LocalTime = LocalTime.of(0, 0), + val end: LocalTime = LocalTime.of(0, 0), + val startDuration: Float = 0f, + val endDuration: Float = 0f, + val duration: Float = 0f, + val color: Color? = null, + val description: String = "" +) \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEvent.kt b/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEvent.kt new file mode 100644 index 000000000..294afd49a --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEvent.kt @@ -0,0 +1,52 @@ +package `in`.koreatech.koin.model.timetable + +import androidx.compose.ui.graphics.Color +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalTime +import kotlin.math.ceil + +data class TimetableEvent( + val id: Int, + val name: String, + val color: Color, + val dayOfWeek: DayOfWeek? = null, + val start: LocalTime, + val end: LocalTime, + val description: String? = null, +) { + fun dayOfWeekToKorean(): String = + when (dayOfWeek) { + DayOfWeek.MONDAY -> "월" + DayOfWeek.TUESDAY -> "화" + DayOfWeek.WEDNESDAY -> "수" + DayOfWeek.THURSDAY -> "목" + DayOfWeek.FRIDAY -> "금" + DayOfWeek.SATURDAY -> "토" + DayOfWeek.SUNDAY -> "일" + else -> "" + } + + fun convertToTimeBlock(endTime: LocalTime): TimeBlock { + val startDuration = Duration.between(LocalTime.of(start.hour, 0), start).run { + this.toMinutes() / 60f + } + val endDuration = Duration.between(start, endTime).run { + this.toMinutes() / 60f + } + val duration = ceil(startDuration + endDuration) + + return TimeBlock( + title = this.name, + start = this.start, + end = endTime, + startDuration = startDuration, + endDuration = endDuration, + duration = duration, + color = this.color, + description = this.description ?: "" + ) + } + + fun convertToEmptyTimeBlock() = TimeBlock() +} diff --git a/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEventType.kt b/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEventType.kt new file mode 100644 index 000000000..d574f0497 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/model/timetable/TimetableEventType.kt @@ -0,0 +1,5 @@ +package `in`.koreatech.koin.model.timetable + +enum class TimetableEventType { + BASIC, SELECTED +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/navigation/KoinNavigationDrawerActivity.kt b/koin/src/main/java/in/koreatech/koin/ui/navigation/KoinNavigationDrawerActivity.kt index b0e8502e1..54f6ee3eb 100644 --- a/koin/src/main/java/in/koreatech/koin/ui/navigation/KoinNavigationDrawerActivity.kt +++ b/koin/src/main/java/in/koreatech/koin/ui/navigation/KoinNavigationDrawerActivity.kt @@ -6,7 +6,6 @@ import android.content.pm.PackageManager import android.graphics.Typeface import android.os.Build import android.os.Bundle -import android.util.Log import android.view.MenuItem import android.view.View import android.widget.Button @@ -59,8 +58,8 @@ abstract class KoinNavigationDrawerActivity : ActivityBase(), val drawerLayoutId get() = R.id.drawer_layout private var pressTime = System.currentTimeMillis() - private val koinNavigationDrawerViewModel by viewModels() + private val koinNavigationDrawerViewModel by viewModels() private val gotoAskForm = registerForActivityResult(GotoAskFormContract()) {} private val drawerLayout by lazy { @@ -137,7 +136,6 @@ abstract class KoinNavigationDrawerActivity : ActivityBase(), override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) - drawerLayout.setScrimColor(ContextCompat.getColor(this, R.color.black_alpha20)) drawerLayout.addDrawerListener { _, slideOffset -> if (slideOffset < 0.5f) window.blueStatusBar() else window.whiteStatusBar() @@ -261,9 +259,12 @@ abstract class KoinNavigationDrawerActivity : ActivityBase(), nameTextview.text = user.name when (menuState) { MenuState.Main, MenuState.Notification -> { - if (!checkMainPermission()) requestMainPermissionLauncher.launch(MAIN_REQUIRED_PERMISSION) + if (!checkMainPermission()) requestMainPermissionLauncher.launch( + MAIN_REQUIRED_PERMISSION + ) koinNavigationDrawerViewModel.updateDeviceToken() } + else -> Unit } } @@ -288,11 +289,13 @@ abstract class KoinNavigationDrawerActivity : ActivityBase(), MenuState.Main -> goToMainActivity() MenuState.Store -> goToStoreActivity() MenuState.Timetable -> { - if (userState.value == null || userState.value?.isAnonymous == true) { - goToAnonymousTimeTableActivity() - } else { - goToTimetableActivty() - } + goToTimetableActivityV2(userState.value, userState.value?.isAnonymous == true) +// if (userState.value == null || userState.value?.isAnonymous == true) { +// goToAnonymousTimeTableActivity() +// } else { +// goToTimetableActivityV2(userState.value, userState.value?.isAnonymous == true) +// goToTimetableActivty() +// } } MenuState.UserInfo -> { @@ -421,6 +424,25 @@ abstract class KoinNavigationDrawerActivity : ActivityBase(), } } + /** + * @TEST + */ + private fun goToTimetableActivityV2(user: User?, isAnonymous: Boolean) { + val intent = + Intent(this, `in`.koreatech.koin.ui.timetablev2.TimetableActivity::class.java).apply { + if (user == null || isAnonymous) { + putExtra("isAnonymous", true) + } else { + putExtra("isAnonymous", false) + } + } + if (menuState != MenuState.Main) { + goToActivityFinish(intent) + } else { + startActivity(intent) + } + } + private fun goToTimetableActivty() { if (menuState != MenuState.Main) { goToActivityFinish(Intent(this, TimetableActivity::class.java)) diff --git a/koin/src/main/java/in/koreatech/koin/ui/splash/SplashActivity.kt b/koin/src/main/java/in/koreatech/koin/ui/splash/SplashActivity.kt index b29b258fd..a1eff8f8d 100644 --- a/koin/src/main/java/in/koreatech/koin/ui/splash/SplashActivity.kt +++ b/koin/src/main/java/in/koreatech/koin/ui/splash/SplashActivity.kt @@ -114,4 +114,9 @@ class SplashActivity : ActivityBase() { firebasePerformanceUtil.stop() } } + + override fun onDestroy() { + loginActivityLauncher.unregister() + super.onDestroy() + } } \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableActivity.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableActivity.kt new file mode 100644 index 000000000..1bb97c647 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableActivity.kt @@ -0,0 +1,131 @@ +package `in`.koreatech.koin.ui.timetablev2 + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.material.BottomSheetState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView +import `in`.koreatech.koin.R +import `in`.koreatech.koin.compose.ui.TimetableTheme +import `in`.koreatech.koin.core.appbar.AppBarBase +import `in`.koreatech.koin.databinding.ActivityTimetableBinding +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.navigation.KoinNavigationDrawerActivity +import `in`.koreatech.koin.ui.navigation.state.MenuState +import `in`.koreatech.koin.ui.timetablev2.view.TimetableScreen +import `in`.koreatech.koin.ui.timetablev2.widget.TimetableAppWidget +import `in`.koreatech.koin.ui.timetablev2.widget.TimetableWidgetReceiver +import `in`.koreatech.koin.util.BitmapUtils +import `in`.koreatech.koin.util.ext.showToast + +class TimetableActivity : KoinNavigationDrawerActivity() { + private lateinit var binding: ActivityTimetableBinding + + override val screenTitle: String + get() = getString(R.string.navigation_item_timetable) + override val menuState: MenuState + get() = MenuState.Timetable + + private var timetableView: MutableState? = null + + @OptIn(ExperimentalMaterialApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTimetableBinding.inflate(layoutInflater) + setContentView(binding.root) + initEvent() + val isAnonymous = intent.getBooleanExtra("isAnonymous", true) + + + binding.composeView.setContent { + TimetableTheme( + darkTheme = false + ) { + val isKeyboardVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + + TimetableScreen( + isAnonymous = isAnonymous, + isKeyboardVisible = isKeyboardVisible, + onSavedImage = { + BitmapUtils(this).apply { + timetableView?.value?.let { view -> + capture(view) { bitmap -> + saveBitmapImage(bitmap) + } + } ?: showToast("retry saved image..") + } + }, + onSendBroadcastReceiver = { semester -> + sendTimetableWidgetReceiver(semester) + }, + content = { bottomSheetState, onEventClick -> + TimetableUI( + sheetState = bottomSheetState, + onEventClick = onEventClick + ) + } + ) + } + } + } + + @OptIn(ExperimentalMaterialApi::class) + @Composable + fun TimetableUI( + sheetState: BottomSheetState, + onEventClick: (TimetableEvent) -> Unit, + ) { + timetableView = remember { + mutableStateOf( + TimetableView( + context = this@TimetableActivity, + sheetState = sheetState, + ) + ) + } + + AndroidView(factory = { + TimetableView( + context = it, + sheetState = sheetState, + ).apply { + post { + timetableView?.value = this + } + setOnTimetableEventClickListener { timetableEvent -> + onEventClick(timetableEvent) + } + } + }) + } + + private fun initEvent() { + handleAppBarEvent() + } + + private fun handleAppBarEvent() { + binding.koinBaseAppbar.setOnClickListener { + when (it.id) { + AppBarBase.getLeftButtonId() -> onBackPressed() + AppBarBase.getRightButtonId() -> toggleNavigationDrawer() + } + } + } + + private fun sendTimetableWidgetReceiver(semester: Semester) { + val intent = Intent(this, TimetableWidgetReceiver::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(TimetableAppWidget.SEMESTER, semester.semester) + } + sendBroadcast(intent) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableSideEffect.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableSideEffect.kt new file mode 100644 index 000000000..beaccde09 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableSideEffect.kt @@ -0,0 +1,5 @@ +package `in`.koreatech.koin.ui.timetablev2 + +sealed class TimetableSideEffect { + data class Toast(val message: String): TimetableSideEffect() +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableState.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableState.kt new file mode 100644 index 000000000..6c1184eab --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableState.kt @@ -0,0 +1,28 @@ +package `in`.koreatech.koin.ui.timetablev2 + +import `in`.koreatech.koin.common.UiStatus +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.model.timetable.TimetableEvent + +data class TimetableState( + val uiStatus: UiStatus = UiStatus.Init, + val isKeyboardVisible: Boolean = false, + val isAnonymous: Boolean = true, + val searchText: String = "", + val isDepartmentDialogVisible: Boolean = false, + val isAddLectureDialogVisible: Boolean = false, + val isRemoveLectureDialogVisible: Boolean = false, + val clickLecture: Lecture = Lecture(), + val selectedLecture: Lecture = Lecture(), + val selectedDepartments: List = emptyList(), + val currentSemester: Semester = Semester(), + val semesters: List = emptyList(), + val _lectures: List = emptyList(), + val lectures: List = emptyList(), + val departments: List = emptyList(), + val timetableEvents: List = emptyList(), + val lectureEvents: List = emptyList(), + val currentDepartments: List = emptyList(), +) diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableView.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableView.kt new file mode 100644 index 000000000..afb3097f7 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/TimetableView.kt @@ -0,0 +1,71 @@ +package `in`.koreatech.koin.ui.timetablev2 + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.material.BottomSheetState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AbstractComposeView +import androidx.lifecycle.viewmodel.compose.viewModel +import `in`.koreatech.koin.compose.ui.defaultColors +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.timetablev2.view.Timetable +import `in`.koreatech.koin.ui.timetablev2.viewmodel.TimetableViewModel +import `in`.koreatech.koin.util.ext.toTimetableEvents +import org.orbitmvi.orbit.compose.collectAsState + +class TimetableView @OptIn(ExperimentalMaterialApi::class) +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + private val sheetState: BottomSheetState, +) : AbstractComposeView(context, attrs, defStyleAttr) { + lateinit var onTimetableEventClickListener: OnTimetableEventClickListener + + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun Content() { + val viewModel: TimetableViewModel = viewModel() + val state by viewModel.collectAsState() + + Timetable( + isKeyboardVisible = state.isKeyboardVisible, + events = generateTimetableEvents(state.timetableEvents), + sheetState = sheetState, + clickEvent = state.lectureEvents, + onEventClick = onTimetableEventClickListener::onEventClick + ) + } + + private fun generateTimetableEvents( + timetableEvents: List, + colors: List = defaultColors + ): List { + val updateTimetableEvents = mutableListOf() + timetableEvents.mapIndexed { index, lecture -> + lecture.toTimetableEvents(index, colors) + }.map { + it.forEach { + updateTimetableEvents.add(it) + } + } + + return updateTimetableEvents + } + + interface OnTimetableEventClickListener { + fun onEventClick(event: TimetableEvent) + } + + inline fun setOnTimetableEventClickListener(crossinline onEventClick: (TimetableEvent) -> Unit) { + this.onTimetableEventClickListener = object : OnTimetableEventClickListener { + override fun onEventClick(event: TimetableEvent) { + onEventClick(event) + } + } + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/CustomAlertDialog.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/CustomAlertDialog.kt new file mode 100644 index 000000000..88790c7a6 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/CustomAlertDialog.kt @@ -0,0 +1,19 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun CustomAlertDialog( + properties: DialogProperties = DialogProperties(), + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + content() + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DayHeader.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DayHeader.kt new file mode 100644 index 000000000..c9f525161 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DayHeader.kt @@ -0,0 +1,30 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun DayHeader( + day: String, + modifier: Modifier = Modifier, +) { + Text( + text = day, + textAlign = TextAlign.Center, + modifier = modifier + .fillMaxWidth() + .padding(4.dp) + ) +} + +@Preview(showBackground = true) +@Composable +private fun DayHeaderPreview() { + DayHeader(day = "월") +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentBox.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentBox.kt new file mode 100644 index 000000000..7c0eed151 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentBox.kt @@ -0,0 +1,77 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.compose.ui.ColorMain400 +import `in`.koreatech.koin.compose.ui.ColorPrimaryMain400_ALPAH10 +import `in`.koreatech.koin.domain.model.timetable.Department + +@Composable +fun DepartmentBox( + department: Department, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: (Department, Boolean) -> Unit, +) { + Box( + modifier = modifier + .clickable { onClick(department, selected) } + .border( + width = 1.dp, + color = if (selected) ColorMain400 else Color.LightGray, + shape = RoundedCornerShape(4.dp) + ) + .background( + color = if (selected) ColorPrimaryMain400_ALPAH10 + else Color.White, + shape = RoundedCornerShape(4.dp) + ) + .padding( + vertical = 6.dp, + ), + contentAlignment = Alignment.Center + ) { + Text( + text = department.name, + fontSize = 10.sp, + color = if (selected) ColorMain400 else Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DepartmentBoxPreviewSelected() { + DepartmentBox( + department = Department(1, "컴퓨터공학과"), + selected = true + ) { _, _ -> + + } +} + +@Preview(showBackground = true) +@Composable +private fun DepartmentBoxPreview() { + DepartmentBox( + department = Department(1, "컴퓨터공학과"), + selected = false + ) { _, _ -> + + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentButton.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentButton.kt new file mode 100644 index 000000000..182c91279 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentButton.kt @@ -0,0 +1,49 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.compose.ui.ColorPrimary + +@Composable +fun DepartmentButton( + modifier: Modifier = Modifier, + onCompleted: () -> Unit, +) { + Box(modifier = modifier) { + Button( + onClick = onCompleted, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(ColorPrimary), + contentPadding = PaddingValues( + horizontal = 14.dp + ), + modifier = Modifier + .height(26.dp) + ) { + Text( + text = "선택완료", + color = Color.White, + fontSize = 15.sp + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DepartmentPreview() { + DepartmentButton( + onCompleted = {} + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentCarouselCard.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentCarouselCard.kt new file mode 100644 index 000000000..982176ba4 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/DepartmentCarouselCard.kt @@ -0,0 +1,84 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.compose.ui.ColorMain400 +import `in`.koreatech.koin.compose.ui.ColorPrimary +import `in`.koreatech.koin.compose.ui.ColorPrimaryMain400_ALPAH10 +import `in`.koreatech.koin.domain.model.timetable.Department + +@Composable +fun DepartmentCarouselCard( + department: Department, + modifier: Modifier = Modifier, + onCancel: (Department) -> Unit, +) { + Card( + elevation = 2.dp, + backgroundColor = Color.White, + shape = RoundedCornerShape(16.dp), + modifier = modifier.border(1.dp, ColorMain400, RoundedCornerShape(16.dp)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(Color.White) + .wrapContentSize() + .padding( + vertical = 2.dp, + horizontal = 6.dp + ) + ) { + Text( + text = department.name, + fontSize = 12.sp, + color = ColorMain400 + ) + Spacer(modifier = Modifier.width(2.dp)) + Box( + modifier = Modifier.clickable { + onCancel(department) + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DepartmentCarouselBoxPreview() { + DepartmentCarouselCard( + Department( + 1, + "컴퓨터공학과" + ), + onCancel = {} + ) +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/LectureItem.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/LectureItem.kt new file mode 100644 index 000000000..59b106a0f --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/LectureItem.kt @@ -0,0 +1,159 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.compose.ui.ColorMain400 +import `in`.koreatech.koin.compose.ui.ColorPrimaryMain400_ALPAH10 +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.util.ext.toTimetableEvents + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun LectureItem( + lecture: Lecture, + colors: List, + selectedLecture: Lecture, + modifier: Modifier = Modifier, + onSelect: (Lecture) -> Unit, + onAddLecture: () -> Unit, + onClick: (List) -> Unit, +) { + val isSelected = selectedLecture == lecture + val events = lecture.toTimetableEvents(colors = colors) + + Column( + modifier = modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { + onClick(events) + if (isSelected) { + onSelect(Lecture()) + } else { + onSelect(lecture) + } + } + ) + .padding( + horizontal = 12.dp, + ) + .background( + color = if (isSelected) { + ColorPrimaryMain400_ALPAH10 + } else { + Color.White + }, + shape = RoundedCornerShape(4.dp) + ) + .padding(12.dp) + ) { + Text( + text = lecture.name, + color = Color.Black, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Row { + events.forEachIndexed { index, event -> + Text( + text = (if (index != 0) "/" else "") + event.dayOfWeekToKorean(), + fontSize = 12.sp, + color = Color.Black + ) + } + Spacer(modifier = Modifier.width(6.dp)) + events.forEachIndexed { index, event -> + Text( + text = (if (index != 0) "/" else "") + "${event.start}-${event.end}", + fontSize = 12.sp, + color = Color.Black + ) + } + } + Text( + text = lecture.formatDescription(), + fontSize = 12.sp, + color = Color.Black, + lineHeight = 14.sp + ) + if (isSelected) { + Card( + shape = RoundedCornerShape(4.dp), + border = BorderStroke(1.dp, ColorMain400), + modifier = Modifier + .background(color = Color.Transparent) + .wrapContentSize(), + onClick = onAddLecture + ) { + Text( + text = "추가", + color = ColorMain400, + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 15.dp, vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + Divider(modifier = Modifier.height(1.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun LectureItemPreview() { + Box(modifier = Modifier.fillMaxSize()) { + LectureItem( + colors = emptyList(), + lecture = Lecture( + name = "직업능력개발훈련평가", + professor = "우성민 우성민우성민우성민우성민우성민우성민우성민우성민우성민우성민우성민우성민우성민", + code = "HRD011", + grades = "2", + lectureClass = "01", + regularNumber = "40", + department = "HRD학과", + target = "전기3", + isEnglish = "", + isElearning = "", + designScore = "0", + classTime = listOf( + 310, + 311, + 312, + 313 + ) + ), + selectedLecture = Lecture(), + onClick = {}, + onSelect = {}, + onAddLecture = {} + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SearchBox.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SearchBox.kt new file mode 100644 index 000000000..7de88659f --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SearchBox.kt @@ -0,0 +1,87 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun SearchBox( + searchText: String, + modifier: Modifier = Modifier, + onSetting: () -> Unit, + onSearchTextChanged: (String) -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.clickable(onClick = onSetting) + ) + Spacer(modifier = Modifier.width(5.dp)) + TextField( + value = searchText, + onValueChange = onSearchTextChanged, + placeholder = { + Text( + text = "입력해주세요.", + fontSize = 15.sp, + color = Color.LightGray + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + colors = TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = Color.DarkGray, + focusedIndicatorColor = Color.Black, + cursorColor = Color.Black, + backgroundColor = Color.White + ), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SearchBoxPreview() { + Box(modifier = Modifier.fillMaxSize()) { + SearchBox( + searchText = "", + onSearchTextChanged = {}, + onSetting = {}, + modifier = Modifier.padding(8.dp) + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SidebarLabel.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SidebarLabel.kt new file mode 100644 index 000000000..032d893ba --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/component/SidebarLabel.kt @@ -0,0 +1,38 @@ +package `in`.koreatech.koin.ui.timetablev2.component + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +private val hourFormatter = DateTimeFormatter.ofPattern("HH") + +@Composable +fun SidebarLabel( + time: LocalTime, + modifier: Modifier = Modifier, +) { + Text( + text = time.format(hourFormatter), + modifier = modifier + .fillMaxSize() + .padding(4.dp), + textAlign = TextAlign.End, + fontSize = 14.sp + ) +} + +@Preview(showBackground = true) +@Composable +private fun SidebarLabelPreview() { + SidebarLabel( + time = LocalTime.now() + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/DepartmentDialog.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/DepartmentDialog.kt new file mode 100644 index 000000000..6111acfb8 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/DepartmentDialog.kt @@ -0,0 +1,84 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.ui.timetablev2.component.CustomAlertDialog +import `in`.koreatech.koin.ui.timetablev2.component.DepartmentBox +import `in`.koreatech.koin.ui.timetablev2.component.DepartmentButton +import `in`.koreatech.koin.ui.timetablev2.viewmodel.TimetableViewModel + +@Composable +fun DepartmentDialog( + visible: Boolean, + selectedDepartments: List, + departments: List, + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + onClick: (Department) -> Unit, + onCompleted: (List) -> Unit, +) { + if (visible) { + CustomAlertDialog( + onDismissRequest = onDismissRequest + ) { + Column( + modifier = modifier + .fillMaxWidth() + .background(Color.White) + .padding( + vertical = 20.dp, + horizontal = 16.dp + ) + ) { + Text( + text = "전공선택", + fontSize = 18.sp, + color = Color.Black + ) + Spacer(modifier = Modifier.height(12.dp)) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = modifier + .background(Color.White) + ) { + itemsIndexed(departments) { index, department -> + DepartmentBox( + modifier = Modifier.padding( + start = if (index % 2 == 0) 0.dp else 2.dp, + end = if (index % 2 == 0) 2.dp else 0.dp, + bottom = 4.dp + ), + department = department, + selected = selectedDepartments.contains(department), + onClick = { selectedDepartment, _ -> + onClick(selectedDepartment) + }, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + DepartmentButton( + modifier = Modifier.align(Alignment.End), + onCompleted = { + onCompleted(selectedDepartments) + } + ) + } + } + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureAddDialog.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureAddDialog.kt new file mode 100644 index 000000000..e7cad9941 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureAddDialog.kt @@ -0,0 +1,66 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.ui.timetablev2.component.CustomAlertDialog + +@Composable +fun LectureAddDialog( + context: Context, + visible: Boolean, + lecture: Lecture, + duplication: Boolean, + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + onAddLecture: (Lecture) -> Unit, +) { + if (visible) { + CustomAlertDialog( + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = modifier + .background(Color.White) + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${lecture.name}(${lecture.lectureClass})", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + text = if (duplication) "기존 강의 대신\n새로운 강의를 추가하시겠습니까?" else "강의를 추가하시겠습니까?", + fontSize = 12.sp, + color = Color.Black, + textAlign = TextAlign.Center + ) + + Button( + onClick = { onAddLecture(lecture) } + ) { + Text(text = "추가하기") + } + } + } + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureRemoveDialog.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureRemoveDialog.kt new file mode 100644 index 000000000..3d64636e2 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/LectureRemoveDialog.kt @@ -0,0 +1,65 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.ui.timetablev2.component.CustomAlertDialog + +@Composable +fun LectureRemoveDialog( + modifier: Modifier = Modifier, + context: Context, + visible: Boolean, + lecture: Lecture, + semester: Semester, + onDismissRequest: () -> Unit, + onRemoveLecture: (Semester, Lecture) -> Unit, +) { + if (visible) { + CustomAlertDialog( + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = modifier + .background(Color.White) + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${lecture.name}(${lecture.lectureClass})", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + text = "강의를 삭제하시겠습니까?", + fontSize = 12.sp, + color = Color.Black + ) + + Button( + onClick = { onRemoveLecture(semester, lecture) } + ) { + Text(text = "삭제하기") + } + } + } + ) + } +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/SemesterDropdown.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/SemesterDropdown.kt new file mode 100644 index 000000000..dc547962f --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/SemesterDropdown.kt @@ -0,0 +1,121 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.domain.model.timetable.Semester + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SemesterDropdown( + semesters: List, + modifier: Modifier = Modifier, + onSemesterTextChanged: (semester: Semester) -> Unit, +) { + var expanded by rememberSaveable { + mutableStateOf(false) + } + var selectedText by rememberSaveable { + mutableStateOf("") + } + + selectedText.ifBlank { + if (semesters.isNotEmpty()) { + onSemesterTextChanged(semesters[0]) + semesters[0].format() + } + else "" + }.let { + selectedText = it + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier + ) { + TextField( + readOnly = true, + value = selectedText, + onValueChange = { }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors( + unfocusedIndicatorColor = Color.Black, + backgroundColor = Color.White, + focusedIndicatorColor = Color.Transparent, + focusedTrailingIconColor = Color.Black, + trailingIconColor = Color.Black, + textColor = Color.Black + ), + shape = RoundedCornerShape(4.dp), + modifier = Modifier + .wrapContentSize() + .border(width = 1.dp, Color.Black, shape = RoundedCornerShape(4.dp)), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + semesters.forEach { semester -> + DropdownMenuItem( + onClick = { + onSemesterTextChanged(semester) + selectedText = semester.format() + expanded = false + } + ) { + Text( + text = semester.format(), + fontSize = 14.sp, + color = Color.Black + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SemesterDropdownPreview() { + val semesters = listOf( + Semester(1, "20241"), + Semester(2, "20242"), + ) + Box(modifier = Modifier.fillMaxSize()) { + SemesterDropdown( + modifier = Modifier + .fillMaxWidth(0.5f) + .padding(4.dp), + semesters = semesters, + onSemesterTextChanged = {} + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/Timetable.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/Timetable.kt new file mode 100644 index 000000000..bcb99f4b2 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/Timetable.kt @@ -0,0 +1,123 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.BottomSheetState +import androidx.compose.material.BottomSheetValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.model.timetable.TimetableEventType + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun Timetable( + isKeyboardVisible: Boolean, + events: List, + modifier: Modifier = Modifier, + clickEvent: List = emptyList(), + sheetState: BottomSheetState = BottomSheetState( + BottomSheetValue.Collapsed, + density = Density(1f) + ), + onEventClick: (TimetableEvent) -> Unit, + eventContent: @Composable + (event: TimetableEvent, eventType: TimetableEventType?, onEventClick: (TimetableEvent) -> Unit) -> Unit = { event, eventType, onEventClick -> + TimetableEventTime(event = event, eventType = eventType, onEventClick = onEventClick) + }, +) { + val screenWidth = (LocalContext.current.resources.displayMetrics.widthPixels / LocalContext.current.resources.displayMetrics.density).dp +// val days = 5 +// val dayWidth = 68.dp + val dayWidth = screenWidth / 6 +// val hourSidebarWidth = +// (LocalContext.current.resources.displayMetrics.widthPixels / LocalContext.current.resources.displayMetrics.density).dp - (dayWidth * days) + val hourSidebarWidth = dayWidth + val hourHeight = 64.dp + var scrollValue by remember { + mutableStateOf(0) + } + val verticalScrollState = rememberScrollState() + + LaunchedEffect(key1 = scrollValue) { + if (clickEvent.isNotEmpty()) { + verticalScrollState.scrollTo(scrollValue) + } + } + + Column( + modifier = modifier + .background(Color.White) + .fillMaxSize() + .padding( + /** + * 바텀 시트 올라올 때, + * 바텀 시트 크기 만큼 Bottom Padding 주기 + */ + bottom = if (sheetState.isExpanded) { + if (sheetState.currentValue == BottomSheetValue.Expanded) { + if (sheetState.targetValue == BottomSheetValue.Expanded && sheetState.progress == 1f) { + if (isKeyboardVisible) { + 500.dp + } else { + 350.dp + } + } else { + 0.dp + } + } else { + 0.dp + } + } else { + 0.dp + } + ) + ) { + TimetableHeader( + modifier = Modifier.fillMaxWidth(), + dayStartPadding = hourSidebarWidth, + ) + Row( + modifier = Modifier + .fillMaxWidth() + ) { + TimetableSidebar( + modifier = Modifier + .verticalScroll(verticalScrollState), + hourHeight = hourHeight, + hourWidth = hourSidebarWidth, + ) + TimetableContent( + modifier = Modifier + .verticalScroll(verticalScrollState), + clickEvent = clickEvent, + eventContent = eventContent, + events = events, + dayWidth = dayWidth, + hourHeight = hourHeight, + onEventY = { eventY -> + if (scrollValue != eventY) { + scrollValue = eventY + } + }, + onEventClick = onEventClick + ) + } + } +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableBottomSheetContent.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableBottomSheetContent.kt new file mode 100644 index 000000000..ff582d588 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableBottomSheetContent.kt @@ -0,0 +1,100 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.compose.ui.defaultColors +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.timetablev2.component.DepartmentCarouselCard +import `in`.koreatech.koin.ui.timetablev2.component.LectureItem +import `in`.koreatech.koin.ui.timetablev2.component.SearchBox + +@Composable +fun TimetableBottomSheetContent( + searchText: String, + isKeyboardVisible: Boolean, + colors: List = defaultColors, + lectures: List, + selectedLectures: Lecture, + currentDepartments: List, + modifier: Modifier = Modifier, + onSetting: () -> Unit, + onCancel: (Department) -> Unit, + onAddLecture: () -> Unit, + onSelectedLecture: (Lecture) -> Unit, + onSearchTextChanged: (String) -> Unit, + onClickLecture: (List) -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .height( + if (isKeyboardVisible) 500.dp else 350.dp + ), + ) { + SearchBox( + modifier = Modifier.padding(8.dp), + searchText = searchText, + onSearchTextChanged = onSearchTextChanged, + onSetting = onSetting + ) + LazyRow( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + ) { + itemsIndexed(currentDepartments) { index, department -> + DepartmentCarouselCard( + modifier = Modifier.padding( + end = if (index == currentDepartments.size) 0.dp else 4.dp + ), + department = department, + onCancel = onCancel + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + LazyColumn { + itemsIndexed(lectures) { _, lecture -> + LectureItem( + colors = colors, + lecture = lecture, + selectedLecture = selectedLectures, + onClick = onClickLecture, + onSelect = onSelectedLecture, + onAddLecture = onAddLecture + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TimetableBottomSheetContentPreview() { + TimetableBottomSheetContent( + isKeyboardVisible = false, + colors = emptyList(), + lectures = emptyList(), + selectedLectures = Lecture(), + currentDepartments = emptyList(), + searchText = "", + onSearchTextChanged = {}, + onClickLecture = {}, + onSelectedLecture = {}, + onAddLecture = {}, + onSetting = {}, + onCancel = {} + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContent.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContent.kt new file mode 100644 index 000000000..1295bce42 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContent.kt @@ -0,0 +1,172 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.model.timetable.TimetableEventType +import java.time.DayOfWeek +import java.time.LocalTime +import java.time.temporal.ChronoUnit +import kotlin.math.roundToInt + +@Composable +fun TimetableContent( + dayWidth: Dp, + hourHeight: Dp, + events: List, + modifier: Modifier = Modifier, + clickEvent: List = emptyList(), + onEventY: (Int) -> Unit, + onEventClick: (event: TimetableEvent) -> Unit, + eventContent: @Composable (event: TimetableEvent, eventType: TimetableEventType, onEventClick: (TimetableEvent) -> Unit) -> Unit = { event, eventType, onClick -> + TimetableEventTime(event = event, eventType = eventType, onEventClick = onClick) + }, +) { + val days = 5 + val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray + val times = 15 + + Layout( + content = { + events.sortedBy(TimetableEvent::start).forEach { event -> + Box(modifier = Modifier.eventData(event)) { + eventContent(event, TimetableEventType.BASIC, onEventClick = onEventClick) + } + } + if (clickEvent.isNotEmpty()) { + clickEvent.sortedBy(TimetableEvent::start).forEach { event -> + Box(modifier = Modifier.eventData(event)) { + eventContent( + event, + TimetableEventType.SELECTED, + onEventClick = onEventClick + ) + } + } + } + }, + modifier = modifier.drawBehind { + drawLine( + dividerColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1.dp.toPx() + ) + drawLine( + dividerColor, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 1.dp.toPx() + ) + + repeat(times * 2) { + drawLine( + dividerColor, + start = Offset(0f, (it + 1) * (hourHeight / 2).toPx()), + end = Offset(size.width, (it + 1) * (hourHeight / 2).toPx()), + strokeWidth = 1.dp.toPx() + ) + } + repeat(days - 1) { + drawLine( + dividerColor, + start = Offset((it + 1) * dayWidth.toPx(), 0f), + end = Offset((it + 1) * dayWidth.toPx(), size.height), + strokeWidth = 1.dp.toPx() + ) + } + } + ) { measureables, constraints -> + val height = hourHeight.roundToPx() * 15 + val width = dayWidth.roundToPx() * days + val placeablesWithEvents = measureables.map { measurable -> + val event = measurable.parentData as TimetableEvent + val eventDurationMinutes = ChronoUnit.MINUTES.between(event.start, event.end) + val eventHeight = ((eventDurationMinutes / 60f) * hourHeight.toPx()).roundToInt() + val placeable = measurable.measure( + constraints.copy( + minWidth = dayWidth.roundToPx(), maxWidth = dayWidth.roundToPx(), + minHeight = eventHeight, maxHeight = eventHeight + ) + ) + Pair(placeable, event) + } + + layout(width, height) { + placeablesWithEvents.forEachIndexed { index, (placeable, event) -> + val initStartTime = LocalTime.of(9, 0) + val eventOffsetMinutes = + ChronoUnit.MINUTES.between(initStartTime, event.start) + val eventY = ((eventOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt() + + val eventOffsetDays: Int = when (event.dayOfWeek) { + DayOfWeek.MONDAY -> 0 + DayOfWeek.TUESDAY -> 1 + DayOfWeek.WEDNESDAY -> 2 + DayOfWeek.THURSDAY -> 3 + DayOfWeek.FRIDAY -> 4 + else -> -1 + } + val eventX = eventOffsetDays * dayWidth.roundToPx() + if (index == placeablesWithEvents.size - 1) { + onEventY(eventY) + } + placeable.place(eventX, eventY) + } + } + } +} + + +private class EventDataModifier( + val event: TimetableEvent, +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?) = event +} + +private fun Modifier.eventData(event: TimetableEvent) = + this.then(EventDataModifier(event)) + +@Preview(showBackground = true) +@Composable +private fun TimetableContentPreview() { + val samples = listOf( + TimetableEvent( + id = 1, + name = "관희의 수업1", + color = Color(0xFFAFBBF2), + dayOfWeek = DayOfWeek.FRIDAY, + start = LocalTime.of(16, 0), + end = LocalTime.of(18, 0), + description = "공학2관 101호", + ), + TimetableEvent( + id = 2, + name = "관희의 수업2", + color = Color(0xFFDEE4FF), + dayOfWeek = DayOfWeek.THURSDAY, + start = LocalTime.of(14, 0), + end = LocalTime.of(16, 0), + description = "공학2관 105호", + ) + ) + + TimetableContent( + events = samples, + dayWidth = 68.dp, + hourHeight = 64.dp, + onEventY = {}, + onEventClick = {} + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContentHeader.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContentHeader.kt new file mode 100644 index 000000000..113d308e1 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableContentHeader.kt @@ -0,0 +1,86 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.compose.ui.ColorPrimary +import `in`.koreatech.koin.domain.model.timetable.Semester + +@Composable +fun TimetableContentHeader( + semesters: List, + modifier: Modifier = Modifier, + onSavedImage: () -> Unit, + onVisibleBottomSheet: () -> Unit, + onSemesterTextChanged: (semester: Semester) -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Max), + verticalAlignment = Alignment.CenterVertically + ) { + SemesterDropdown( + semesters = semesters, + modifier = Modifier + .weight(1f) + .padding(4.dp), + onSemesterTextChanged = onSemesterTextChanged + ) + TimetableSaveButton( + modifier = Modifier + .padding(4.dp) + .fillMaxHeight() + .background(color = ColorPrimary, shape = RoundedCornerShape(4.dp)) + .padding(8.dp), + onClick = onSavedImage + ) + Icon( + imageVector = Icons.Default.AddCircle, + contentDescription = null, + tint = ColorPrimary, + modifier = Modifier + .padding(start = 4.dp, end = 8.dp) + .size(30.dp) + .clickable { + onVisibleBottomSheet() + } + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TimetableContentHeaderPreview() { + val semesters = listOf( + Semester(1, "20241"), + Semester(2, "20242"), + ) + Box(modifier = Modifier.fillMaxSize()) { + TimetableContentHeader( + semesters = semesters, + onSavedImage = {}, + onVisibleBottomSheet = {}, + onSemesterTextChanged = {} + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableEventTime.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableEventTime.kt new file mode 100644 index 000000000..df2ad49c0 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableEventTime.kt @@ -0,0 +1,107 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.model.timetable.TimetableEventType +import java.time.DayOfWeek +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +val timetableEventTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +@Composable +fun TimetableEventTime( + event: TimetableEvent, + modifier: Modifier = Modifier, + eventType: TimetableEventType? = null, + onEventClick: (event: TimetableEvent) -> Unit, +) { + Column( + modifier = modifier + .padding(bottom = 2.dp, end = 2.dp) + .fillMaxSize() + .background( + color = if (eventType == TimetableEventType.SELECTED) Color.Transparent else event.color, + shape = RoundedCornerShape(4.dp) + ) + .border( + color = if (eventType == TimetableEventType.SELECTED) Color.Red else Color.Transparent, + width = if (eventType == TimetableEventType.SELECTED) 1.dp else 0.dp, + shape = RoundedCornerShape(4.dp) + ) + .padding(4.dp) + .clickable( + enabled = if (eventType == TimetableEventType.SELECTED) false else true + ) { + onEventClick(event) + } + ) { + if (eventType == TimetableEventType.BASIC) Divider(color = Color.White, thickness = 1.dp) + Spacer(modifier = Modifier.height(2.dp)) + when (eventType) { + TimetableEventType.BASIC -> { + Text( + text = event.name, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + lineHeight = 14.sp, + ) + + if (event.description != null) { + Text( + text = event.description, + fontSize = 8.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.Black + ) + } + } + + TimetableEventType.SELECTED -> {} + null -> {} + } + + } +} + +@Preview(showBackground = true) +@Composable +private fun TimetableEventTimePreview() { + val sample = TimetableEvent( + id = 1, + name = "관희의 수업", + color = Color(0xFFAFBBF2), + dayOfWeek = DayOfWeek.FRIDAY, + start = LocalTime.of(16, 0), + end = LocalTime.of(18, 0), + description = "공학2관 105호", + ) + + TimetableEventTime( + event = sample, + eventType = TimetableEventType.BASIC, + modifier = Modifier.sizeIn(maxHeight = 64.dp), + onEventClick = {} + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableHeader.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableHeader.kt new file mode 100644 index 000000000..08f7f6b38 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableHeader.kt @@ -0,0 +1,43 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.ui.timetablev2.component.DayHeader + +@Composable +fun TimetableHeader( + dayStartPadding: Dp, + modifier: Modifier = Modifier, + dayHeader: @Composable (day: String) -> Unit = { DayHeader(day = it) }, +) { + Row( + modifier = modifier + .background(Color.White) + .border(width = (0.5).dp, color = Color.LightGray) + .padding(start = dayStartPadding) + ) { + val days = listOf("월", "화", "수", "목", "금") + repeat(days.size) { + Box(modifier = Modifier.weight(1f)) { + dayHeader(day = days[it]) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TimetableHeaderPreview() { + TimetableHeader( + dayStartPadding = 10.dp + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSaveButton.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSaveButton.kt new file mode 100644 index 000000000..93c125008 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSaveButton.kt @@ -0,0 +1,56 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import `in`.koreatech.koin.R +import `in`.koreatech.koin.compose.ui.ColorPrimary + +@Composable +fun TimetableSaveButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier.clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.ic_save_image), + contentDescription = null + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(id = R.string.timetable_save_image), + color = Color.White, + fontSize = 12.sp + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TimetableSaveButtonPreview() { + Box(modifier = Modifier.fillMaxSize()) { + TimetableSaveButton( + modifier = Modifier + .background(ColorPrimary), + onClick = {} + ) + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableScreen.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableScreen.kt new file mode 100644 index 000000000..60d31f1d2 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableScreen.kt @@ -0,0 +1,205 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import android.content.Context +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material.BottomSheetState +import androidx.compose.material.BottomSheetValue +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.material.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import `in`.koreatech.koin.common.UiStatus +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.timetablev2.TimetableSideEffect +import `in`.koreatech.koin.ui.timetablev2.viewmodel.TimetableViewModel +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun TimetableScreen( + isAnonymous: Boolean, + isKeyboardVisible: Boolean, + modifier: Modifier = Modifier, + context: Context = LocalContext.current, + timetableViewModel: TimetableViewModel = viewModel(), + onSavedImage: () -> Unit, + onSendBroadcastReceiver: (semester: Semester) -> Unit, + content: @Composable ColumnScope.(sheetState: BottomSheetState, onEventClick: (TimetableEvent) -> Unit) -> Unit +) { + val state by timetableViewModel.collectAsState() + + timetableViewModel.collectSideEffect { + when (it) { + is TimetableSideEffect.Toast -> Unit + } + } + + LaunchedEffect(key1 = state.semesters) { + if (state.semesters.isEmpty()) { + timetableViewModel.loadSemesters() + } + if (state.departments.isEmpty()) { + timetableViewModel.loadDepartments() + } + } + + if (state.currentSemester.semester.isNotEmpty()) { + onSendBroadcastReceiver(state.currentSemester) + } + + LaunchedEffect(key1 = isAnonymous) { + timetableViewModel.updateIsAnonymous(isAnonymous) + } + + LaunchedEffect(key1 = isKeyboardVisible) { + timetableViewModel.updateIsKeyboardVisible(isKeyboardVisible) + } + + if (state.currentSemester.semester.isBlank() && state.semesters.isNotEmpty()) { + timetableViewModel.updateCurrentSemester(state.semesters[0]) + } + + val scope = rememberCoroutineScope() + val sheetState = rememberBottomSheetState( + initialValue = BottomSheetValue.Collapsed + ) + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = sheetState + ) + + BackHandler(sheetState = sheetState) + + DepartmentDialog( + visible = state.isDepartmentDialogVisible, + departments = state.departments, + selectedDepartments = state.selectedDepartments, + onDismissRequest = { + if (state.currentDepartments.isEmpty()) { + timetableViewModel.clearSelectedDepartments() + } + timetableViewModel.closeDepartmentDialog() + }, + onClick = timetableViewModel::updateSelectedDepartment, + onCompleted = timetableViewModel::updateCurrentDepartment + ) + + LectureAddDialog( + context = context, + visible = state.isAddLectureDialogVisible, + lecture = state.selectedLecture, + duplication = state.selectedLecture.duplicate(state.timetableEvents), + onDismissRequest = timetableViewModel::closeAddLectureDialog, + onAddLecture = { lecture -> + if (state.selectedLecture.duplicate(state.timetableEvents)) { + timetableViewModel.duplicateLecture(lecture) + } else { + timetableViewModel.addLecture(state.currentSemester, lecture) + } + timetableViewModel.closeAddLectureDialog() + onSendBroadcastReceiver(state.currentSemester) + } + ) + + LectureRemoveDialog( + context = context, + visible = state.isRemoveLectureDialogVisible, + lecture = state.clickLecture, + semester = state.currentSemester, + onDismissRequest = timetableViewModel::closeRemoveLectureDialog, + onRemoveLecture = { semester, lecture -> + timetableViewModel.removeLecture(semester, lecture) + onSendBroadcastReceiver(state.currentSemester) + } + ) + + BottomSheetScaffold( + modifier = modifier, + scaffoldState = scaffoldState, + sheetContent = { + TimetableBottomSheetContent( + searchText = state.searchText, + isKeyboardVisible = state.isKeyboardVisible, + lectures = state.lectures, + selectedLectures = state.selectedLecture, + currentDepartments = state.currentDepartments, + onSetting = timetableViewModel::openDepartmentDialog, + onCancel = timetableViewModel::removeDepartment, + onAddLecture = timetableViewModel::openAddLectureDialog, + onSelectedLecture = timetableViewModel::updateSelectedLecture, + onSearchTextChanged = timetableViewModel::updateSearchText, + onClickLecture = timetableViewModel::updateLectureEvent + ) + }, + sheetBackgroundColor = Color.White, + sheetPeekHeight = 0.dp, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) { + Column( + modifier = Modifier.fillMaxHeight() + ) { + TimetableContentHeader( + semesters = state.semesters, + modifier = Modifier.fillMaxWidth(), + onSavedImage = onSavedImage, + onVisibleBottomSheet = { + scope.launch { + if (sheetState.isCollapsed) sheetState.expand() + else sheetState.collapse() + } + }, + onSemesterTextChanged = timetableViewModel::updateCurrentSemester + ) + content( + sheetState = sheetState, + onEventClick = timetableViewModel::updateClickLecture + ) + } + } + + when (state.uiStatus) { + is UiStatus.Failed -> Unit + UiStatus.Init -> Unit + UiStatus.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(50.dp)) + } + } + + UiStatus.Success -> Unit + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun BackHandler(sheetState: BottomSheetState) { + val scope = rememberCoroutineScope() + + BackHandler(enabled = sheetState.isExpanded) { + scope.launch { + sheetState.collapse() + } + } +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSidebar.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSidebar.kt new file mode 100644 index 000000000..2bfde7638 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/view/TimetableSidebar.kt @@ -0,0 +1,55 @@ +package `in`.koreatech.koin.ui.timetablev2.view + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.ui.timetablev2.component.SidebarLabel +import java.time.LocalTime + +@Composable +fun TimetableSidebar( + hourHeight: Dp, + hourWidth: Dp, + modifier: Modifier = Modifier, + label: @Composable (time: LocalTime) -> Unit = { SidebarLabel(time = it) }, +) { + val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray + val startTime = LocalTime.of(9, 0) + val times = 15 + + Column( + modifier = modifier, + ) { + repeat(times) { + Box( + modifier = Modifier + .size(height = hourHeight, width = hourWidth) + .drawBehind { + drawLine( + dividerColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1.dp.toPx() + ) + } + ) { + label(startTime.plusHours(it.toLong())) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TimetableSidebarPreview() { + TimetableSidebar(hourHeight = 64.dp, hourWidth = 68.dp) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/viewmodel/TimetableViewModel.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/viewmodel/TimetableViewModel.kt new file mode 100644 index 000000000..08d00df7e --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/viewmodel/TimetableViewModel.kt @@ -0,0 +1,278 @@ +package `in`.koreatech.koin.ui.timetablev2.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.koreatech.koin.common.UiStatus +import `in`.koreatech.koin.domain.model.timetable.Department +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.model.timetable.Semester +import `in`.koreatech.koin.domain.usecase.timetable.GetDepartmentsUseCase +import `in`.koreatech.koin.domain.usecase.timetable.GetLecturesUseCase +import `in`.koreatech.koin.domain.usecase.timetable.GetSemesterUseCase +import `in`.koreatech.koin.domain.usecase.timetable.GetTimetablesUseCase +import `in`.koreatech.koin.domain.usecase.timetable.RemoveTimetablesUseCase +import `in`.koreatech.koin.domain.usecase.timetable.UpdateSemesterUseCase +import `in`.koreatech.koin.domain.usecase.timetable.UpdateTimetablesUseCase +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.timetablev2.TimetableSideEffect +import `in`.koreatech.koin.ui.timetablev2.TimetableState +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class TimetableViewModel @Inject constructor( + private val getSemesterUseCase: GetSemesterUseCase, + private val getLecturesUseCase: GetLecturesUseCase, + private val getDepartmentsUseCase: GetDepartmentsUseCase, + private val getTimetablesUseCase: GetTimetablesUseCase, + private val updateTimetablesUseCase: UpdateTimetablesUseCase, + private val updateSemesterUseCase: UpdateSemesterUseCase, + private val removeTimetablesUseCase: RemoveTimetablesUseCase, +) : ContainerHost, ViewModel() { + override val container: Container = + container(TimetableState()) + + init { + observeSearchTextAndDepartments() + } + + private fun observeSearchTextAndDepartments() { + intent { + combine( + container.stateFlow.map { it.searchText }, + container.stateFlow.map { it.currentDepartments } + ) { searchText, currentDepartments -> + Pair(searchText, currentDepartments) + }.collect { (searchText, currentDepartments) -> + updateLectures(searchText, currentDepartments) + } + } + } + + private fun updateLectures(searchText: String, currentDepartments: List) = intent { + reduce { + if (searchText.isBlank() && currentDepartments.isEmpty()) { + state.copy(lectures = state._lectures) + } else if (currentDepartments.isEmpty()) { + state.copy( + lectures = state._lectures.filter { lecture -> + lecture.doesMatchSearchQuery(searchText) + } + ) + } else { + state.copy( + lectures = state._lectures.filter { lecture -> + lecture.doesMatchDepartmentSearchQuery(currentDepartments.map { it.name }) && + (searchText.isBlank() || lecture.doesMatchSearchQuery(searchText)) + } + ) + } + } + } + + fun clear() = intent { + reduce { + state.copy( + selectedLecture = Lecture(), + lectureEvents = emptyList(), + clickLecture = Lecture() + ) + } + } + + fun clearSelectedDepartments() = intent { + reduce { state.copy(selectedDepartments = emptyList()) } + } + + fun loadSemesters() = intent { + viewModelScope.launch { + getSemesterUseCase().let { + reduce { state.copy(semesters = it) } + } + } + } + + fun loadDepartments() = intent { + viewModelScope.launch { + getDepartmentsUseCase().let { + reduce { state.copy(departments = it) } + } + } + } + + fun openAddLectureDialog() = intent { + reduce { state.copy(isAddLectureDialogVisible = true) } + } + + fun openRemoveLectureDialog() = intent { + reduce { state.copy(isRemoveLectureDialogVisible = true) } + } + + fun openDepartmentDialog() = intent { + reduce { state.copy(isDepartmentDialogVisible = true) } + } + + fun closeAddLectureDialog() = intent { + reduce { state.copy(isAddLectureDialogVisible = false) } + } + + fun closeRemoveLectureDialog() = intent { + reduce { state.copy(isRemoveLectureDialogVisible = false) } + } + + fun closeDepartmentDialog() = intent { + reduce { state.copy(isDepartmentDialogVisible = false) } + } + + fun updateIsKeyboardVisible(visible: Boolean) = intent { + reduce { state.copy(isKeyboardVisible = visible) } + } + + fun updateIsAnonymous(isAnonymous: Boolean) = intent { + reduce { state.copy(isAnonymous = isAnonymous) } + } + + fun updateSearchText(text: String) = intent { + reduce { state.copy(searchText = text) } + } + + fun updateLectureEvent(timetableEvents: List) = intent { + reduce { state.copy(lectureEvents = timetableEvents) } + } + + fun updateClickLecture(event: TimetableEvent) = intent { + val updatedLecture = + state.timetableEvents.filter { it.id == event.id }.getOrElse(0) { Lecture() } + reduce { + state.copy( + clickLecture = updatedLecture, + isRemoveLectureDialogVisible = true + ) + } + } + + fun updateSelectedLecture(lecture: Lecture) = intent { + if (lecture == Lecture()) { + updateLectureEvent(emptyList()) + } + reduce { state.copy(selectedLecture = lecture) } + } + + fun updateSelectedDepartment(department: Department) = intent { + val updatedDepartments = state.selectedDepartments.toMutableList() + if (state.selectedDepartments.contains(department)) { + updatedDepartments.remove(department) + } else { + updatedDepartments.add(department) + } + + reduce { state.copy(selectedDepartments = updatedDepartments) } + } + + fun updateCurrentDepartment(departments: List) = intent { + reduce { + state.copy( + currentDepartments = departments, + isDepartmentDialogVisible = false + ) + } + } + + fun updateCurrentSemester(semester: Semester) = intent { + updateSemesterUseCase(semester.semester) + clear() + reduce { state.copy(uiStatus = UiStatus.Loading) } + viewModelScope.launch { + val lectures = getLecturesUseCase(semester.semester) + val timetables = getTimetablesUseCase(semester.semester, state.isAnonymous) + + reduce { + state.copy( + uiStatus = UiStatus.Success, + lectures = lectures, + _lectures = lectures, + timetableEvents = timetables, + currentSemester = semester + ) + } + } + } + + fun addLecture(semester: Semester, lecture: Lecture) = intent { + val updateTimetableEvents = state.timetableEvents.toMutableList() + updateTimetableEvents.add(lecture) + + reduce { state.copy(uiStatus = UiStatus.Loading) } + viewModelScope.launch { + if (state.isAnonymous) { + updateTimetablesUseCase(semester.semester, state.isAnonymous, updateTimetableEvents) + } else { + updateTimetablesUseCase(semester.semester, state.isAnonymous, listOf(lecture)) + } + val timetables = getTimetablesUseCase(semester.semester, state.isAnonymous) + reduce { + state.copy( + uiStatus = UiStatus.Success, + timetableEvents = timetables, + currentSemester = semester, + lectureEvents = emptyList(), + clickLecture = Lecture(), + selectedLecture = Lecture() + ) + } + } + } + + fun removeLecture(semester: Semester, lecture: Lecture) = intent { + clear() + val updateTimetableEvents = state.timetableEvents.toMutableList() + updateTimetableEvents.remove(lecture) + + reduce { state.copy(uiStatus = UiStatus.Loading, isRemoveLectureDialogVisible = false) } + viewModelScope.launch { + if (state.isAnonymous) { + updateTimetablesUseCase(semester.semester, state.isAnonymous, updateTimetableEvents) + } else { + removeTimetablesUseCase(lecture.id) + } + + val timetables = getTimetablesUseCase(semester.semester, state.isAnonymous) + reduce { + state.copy( + uiStatus = UiStatus.Success, + timetableEvents = timetables, + ) + } + } + } + + fun removeDepartment(department: Department) = intent { + val updateDepartments = state.currentDepartments.toMutableList() + updateDepartments.remove(department) + + reduce { + state.copy( + uiStatus = UiStatus.Success, + currentDepartments = updateDepartments, + selectedDepartments = updateDepartments + ) + } + } + + fun duplicateLecture(lecture: Lecture) = intent { + lecture.classTime.forEach { time -> + state.timetableEvents.filter { it.classTime.contains(time) }.forEach { lecture -> + removeLecture(state.currentSemester, lecture) + } + } + addLecture(state.currentSemester, lecture) + } +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableAppWidget.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableAppWidget.kt new file mode 100644 index 000000000..50b9708e8 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableAppWidget.kt @@ -0,0 +1,142 @@ +package `in`.koreatech.koin.ui.timetablev2.widget + +import android.content.Context +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.fillMaxSize +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import `in`.koreatech.koin.R +import `in`.koreatech.koin.compose.ui.basicColors +import `in`.koreatech.koin.compose.ui.defaultColors +import `in`.koreatech.koin.data.source.local.TokenLocalDataSource +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.domain.repository.TimetableRepository +import `in`.koreatech.koin.model.timetable.TimeBlock +import `in`.koreatech.koin.model.timetable.TimetableEvent +import `in`.koreatech.koin.ui.timetablev2.widget.view.TimetableWidgetScreen +import `in`.koreatech.koin.util.ext.toTimetableEvents +import `in`.koreatech.koin.util.mapper.toTimeBlocks +import java.time.DayOfWeek + +object TimetableAppWidget : GlanceAppWidget() { + const val SEMESTER = "semester" + const val LAST_UPDATED = "lastUpdated" + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val appContext = context.applicationContext + val timetableRepository = timetableRepository(context) + + provideContent { + val state = currentState() + val semester = state[stringPreferencesKey(SEMESTER)] ?: "" + val lastUpdated = state[stringPreferencesKey(LAST_UPDATED)] ?: "" + val timeBlocks = remember { + mutableStateOf>>(emptyList()) + } + + LaunchedEffect(key1 = lastUpdated) { + timetableRepository.getTimetables(semester, true).let { timetables -> + timeBlocks.value = generateDayOfWeekTimetables(timetables) + } + } + + TimetableWidgetScreen( + timeBlocks = timeBlocks.value, + modifier = GlanceModifier.fillMaxSize() + .background(Color.White) + ) + } + } + + private fun timetableRepository(context: Context): TimetableRepository { + val hiltEntryPoint = EntryPointAccessors.fromApplication( + context, TimetableWidgetEntryPoint::class.java + ) + return hiltEntryPoint.timetableRepository() + } + + private fun generateTimetableEvents( + lectures: List, + colors: List = defaultColors + ): List { + val updatedTimetableEvents = mutableListOf() + lectures.mapIndexed { index, lecture -> + lecture.toTimetableEvents(index, colors) + }.map { + it.forEach { + updatedTimetableEvents.add(it) + } + } + return updatedTimetableEvents + } + + private fun generateDayOfWeekTimetables(timetableEvents: List): List> { + val timeBlocks = MutableList>(5) { emptyList() } + + val mondays = + generateTimetableEvents( + timetableEvents, + ).filter { it.dayOfWeek == DayOfWeek.MONDAY } + .sortedBy { it.start }.toTimeBlocks() + val tuesdays = + generateTimetableEvents( + timetableEvents, + ).filter { it.dayOfWeek == DayOfWeek.TUESDAY } + .sortedBy { it.start }.toTimeBlocks() + val wednesdays = + generateTimetableEvents( + timetableEvents, + ).filter { it.dayOfWeek == DayOfWeek.WEDNESDAY } + .sortedBy { it.start }.toTimeBlocks() + val thursday = + generateTimetableEvents( + timetableEvents, + ).filter { it.dayOfWeek == DayOfWeek.THURSDAY } + .sortedBy { it.start }.toTimeBlocks() + val fridays = + generateTimetableEvents( + timetableEvents, + ).filter { it.dayOfWeek == DayOfWeek.FRIDAY } + .sortedBy { it.start }.toTimeBlocks() + + timeBlocks[0] = mondays + timeBlocks[1] = tuesdays + timeBlocks[2] = wednesdays + timeBlocks[3] = thursday + timeBlocks[4] = fridays + + return timeBlocks + } +} + +class RefreshAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + TimetableAppWidget.update(context, glanceId) + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface TimetableWidgetEntryPoint { + fun timetableRepository(): TimetableRepository +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableWidgetReceiver.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableWidgetReceiver.kt new file mode 100644 index 000000000..7814521a6 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/TimetableWidgetReceiver.kt @@ -0,0 +1,48 @@ +package `in`.koreatech.koin.ui.timetablev2.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class TimetableWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TimetableAppWidget + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + val action = intent.action + + when (action) { + AppWidgetManager.ACTION_APPWIDGET_UPDATE -> { + val semester = intent.getStringExtra(TimetableAppWidget.SEMESTER) ?: "" + CoroutineScope(Dispatchers.IO).launch { + updateWidget(context, semester) + } + } + } + } + + suspend fun updateWidget( + context: Context, + semester: String + ) { + val updatedTime = System.currentTimeMillis().toString() + GlanceAppWidgetManager(context).getGlanceIds(TimetableAppWidget.javaClass) + .forEach { glanceId -> + updateAppWidgetState(context, glanceId) { prefs -> + prefs[stringPreferencesKey(TimetableAppWidget.SEMESTER)] = semester + prefs[stringPreferencesKey(TimetableAppWidget.LAST_UPDATED)] = updatedTime + } + } + TimetableAppWidget.updateAll(context) + } +} diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetContent.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetContent.kt new file mode 100644 index 000000000..f2901f0fc --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetContent.kt @@ -0,0 +1,131 @@ +package `in`.koreatech.koin.ui.timetablev2.widget.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import `in`.koreatech.koin.R +import `in`.koreatech.koin.model.timetable.TimeBlock + +@Composable +fun TimetableWidgetContent( + timeWidth: Float, + timeBlocks: List>, + modifier: GlanceModifier = GlanceModifier +) { + Row( + modifier = modifier + ) { + if (timeBlocks.isNotEmpty()) { + repeat(timeBlocks.size + 2) { index -> + Column( + modifier = GlanceModifier + .width((timeWidth / 6f).dp) + .fillMaxHeight() + ) { + for (i in 9..18) { + if (index == 0) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .height(60.dp) + .background(imageProvider = ImageProvider(R.drawable.shape_timetable_row)), + contentAlignment = Alignment.TopEnd + ) { + Text( + text = i.toString(), + style = TextStyle( + fontSize = 12.sp, + color = ColorProvider(Color.Black) + ), + modifier = GlanceModifier + .padding(top = 2.dp, end = 2.dp) + ) + } + } else if (index in 1..5) { + val timeBlock = timeBlocks[index - 1][i - 9] + Box( + modifier = GlanceModifier + .fillMaxWidth() + .height( + if (timeBlock == null) { + 60.dp + } else { + (timeBlock.duration * 60).dp + } + ) + .padding( + top = ((timeBlock?.startDuration ?: 0f) * 60).dp, + ) + .background(imageProvider = ImageProvider(R.drawable.shape_timetable_row)) + ) { + Column( + modifier = GlanceModifier + .fillMaxWidth() + .height( + ((timeBlock?.endDuration ?: 0f) * 60).dp + ) + .padding(2.dp) + .background( + timeBlock?.color ?: Color.Transparent + ) + ) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White) + ) { + + } + Text( + text = timeBlock?.title ?: "", + style = TextStyle( + fontSize = 10.sp, + color = ColorProvider(Color.Black), + fontWeight = FontWeight.Bold + ) + ) + Text( + text = timeBlock?.description ?: "", + style = TextStyle( + fontSize = 8.sp, + color = ColorProvider(Color.Black) + ) + ) + } + } + } + } + } + } + } + } +} + +@Preview +@Composable +fun TimetableWidgetContentPreview() { + TimetableWidgetContent( + timeWidth = 1f, + timeBlocks = emptyList(), + modifier = GlanceModifier.fillMaxWidth() + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetHeader.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetHeader.kt new file mode 100644 index 000000000..e6c0e5b3d --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetHeader.kt @@ -0,0 +1,75 @@ +package `in`.koreatech.koin.ui.timetablev2.widget.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import `in`.koreatech.koin.R +import `in`.koreatech.koin.ui.timetablev2.widget.RefreshAction + +@Composable +fun TimetableWidgetHeader( + modifier: GlanceModifier = GlanceModifier +) { + val headerTitle = listOf("", "월", "화", "수", "목", "금") + Row( + modifier = modifier + ) { + headerTitle.forEachIndexed { index, title -> + Box( + modifier = GlanceModifier + .defaultWeight() + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + if (title.isEmpty()) { + Image( + provider = ImageProvider(R.drawable.icon_refresh), + contentDescription = null, + modifier = GlanceModifier.clickable(onClick = actionRunCallback()) + ) + } else { + Text( + text = title, + style = TextStyle( + color = ColorProvider(Color.Black), + fontSize = 14.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + ) + } + } + } + } + +} + +@Preview(showBackground = true) +@Composable +fun TimetableWidgetHeaderPreview() { + TimetableWidgetHeader( + modifier = GlanceModifier + .fillMaxWidth() + .height(40.dp) + .background(Color.Transparent) + ) +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetScreen.kt b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetScreen.kt new file mode 100644 index 000000000..f589d9146 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/ui/timetablev2/widget/view/TimetableWidgetScreen.kt @@ -0,0 +1,41 @@ +package `in`.koreatech.koin.ui.timetablev2.widget.view + +import android.appwidget.AppWidgetManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.LocalAppWidgetOptions +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.background +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import `in`.koreatech.koin.model.timetable.TimeBlock + +@Composable +fun TimetableWidgetScreen( + timeBlocks: List>, + modifier: GlanceModifier = GlanceModifier, + timeWidth: Int = LocalAppWidgetOptions.current.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) +) { + LazyColumn( + modifier = modifier + ) { + item { + TimetableWidgetHeader( + modifier = GlanceModifier + .fillMaxWidth() + .height(40.dp) + .background(Color.Transparent) + ) + } + item { + TimetableWidgetContent( + modifier = GlanceModifier.fillMaxWidth(), + timeWidth = timeWidth.toFloat(), + timeBlocks = timeBlocks + ) + } + } +} + diff --git a/koin/src/main/java/in/koreatech/koin/util/BitmapUtils.kt b/koin/src/main/java/in/koreatech/koin/util/BitmapUtils.kt new file mode 100644 index 000000000..17431c715 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/util/BitmapUtils.kt @@ -0,0 +1,101 @@ +package `in`.koreatech.koin.util + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.view.View +import `in`.koreatech.koin.util.ext.showToast +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +class BitmapUtils ( + private val context: Context +) { + fun capture(view: View, onSavedTimeTable: (Bitmap) -> Unit) { + val bitmap = generateBitmap(view) + onSavedTimeTable(bitmap) + } + + fun saveBitmapImage( + bitmap: Bitmap, + ) { + val timeStamp = System.currentTimeMillis() + + val values = ContentValues() + values.put(MediaStore.Images.Media.MIME_TYPE, "image/png") + values.put(MediaStore.Images.Media.DATE_ADDED, timeStamp) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.put(MediaStore.Images.Media.DATE_TAKEN, timeStamp) + // Pictures/앱 이름 + values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/" + "koin") + values.put(MediaStore.Images.Media.IS_PENDING, true) + val uri = + context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + if (uri != null) { + try { + val outputStream = context.contentResolver.openOutputStream(uri) + if (outputStream != null) { + try { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.close() + } catch (e: Exception) { + e.message + } + } + values.put(MediaStore.Images.Media.IS_PENDING, false) + context.contentResolver.update(uri, values, null, null) + + context.showToast("Saved...") + } catch (e: Exception) { + e.message + } + } else { + val imageFileFolder = + File(Environment.getExternalStorageDirectory().toString() + "/" + "koin") + if (!imageFileFolder.exists()) { + imageFileFolder.mkdirs() + } + val mImageName = "$timeStamp.png" + val imageFile = File(imageFileFolder, mImageName) + try { + val outputStream: OutputStream = FileOutputStream(imageFile) + try { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.close() + } catch (e: Exception) { + e.message + } + values.put(MediaStore.Images.Media.DATA, imageFile.absolutePath) + context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + + context.showToast("Saved...") + } catch (e: Exception) { + e.message + } + } + } + } + + fun generateBitmap(view: View): Bitmap { + val bitmap = Bitmap.createBitmap( + view.width, + view.height, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + view.layout( + view.left, + view.top, + view.right, + view.bottom + ) + view.draw(canvas) + return bitmap + } +} \ No newline at end of file diff --git a/koin/src/main/java/in/koreatech/koin/util/ext/TimetableExtensions.kt b/koin/src/main/java/in/koreatech/koin/util/ext/TimetableExtensions.kt new file mode 100644 index 000000000..2f3fcef41 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/util/ext/TimetableExtensions.kt @@ -0,0 +1,37 @@ +package `in`.koreatech.koin.util.ext + +import androidx.compose.ui.graphics.Color +import `in`.koreatech.koin.compose.ui.ColorPrimary +import `in`.koreatech.koin.compose.ui.basicColors +import `in`.koreatech.koin.compose.ui.defaultColors +import `in`.koreatech.koin.domain.model.timetable.Lecture +import `in`.koreatech.koin.model.timetable.TimetableEvent +import java.time.LocalTime + +fun Lecture.toTimetableEvents(index: Int? = null, colors: List): List { + val events = mutableListOf() + /** + * @input : {MONDAY=[09:00, 09:30], TUESDAY=[09:00, 09:30]} + */ + findDayOfWeekAndTime().forEach { (key, value) -> + val description = if (grades.length == 1) "0${grades} ${this.professor}" else "$grades $professor" + val timetableEvent = TimetableEvent( + id = id, + name = name, + color = colors[(if (index != null) index + 1 else 0) % colors.size], + dayOfWeek = key, + start = value.firstOrNull() ?: LocalTime.of(0, 0), + end = value.lastOrNull()?.plusMinutes(30) ?: LocalTime.of(0, 0), + description = description + ) + events.add(timetableEvent) + } + /** + * @output : + * [ + * TimetableEvent(0, "강의이름1", 색상1, MONDAY, 09:00, 09:30, null), + * TimetableEvent(0, "강의이름2", 색상2, TUESDAY, 09:00, 09:30, null), + * ] + */ + return events +} diff --git a/koin/src/main/java/in/koreatech/koin/util/ext/TypeExtensions.kt b/koin/src/main/java/in/koreatech/koin/util/ext/TypeExtensions.kt new file mode 100644 index 000000000..615aaef5e --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/util/ext/TypeExtensions.kt @@ -0,0 +1,8 @@ +package `in`.koreatech.koin.util.ext + +import android.content.res.Resources +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val Float.pxToDp: Dp + get() = (this / Resources.getSystem().displayMetrics.density).dp diff --git a/koin/src/main/java/in/koreatech/koin/util/mapper/TimetableMapperUtils.kt b/koin/src/main/java/in/koreatech/koin/util/mapper/TimetableMapperUtils.kt new file mode 100644 index 000000000..e2f36c5b9 --- /dev/null +++ b/koin/src/main/java/in/koreatech/koin/util/mapper/TimetableMapperUtils.kt @@ -0,0 +1,36 @@ +package `in`.koreatech.koin.util.mapper + +import `in`.koreatech.koin.model.timetable.TimeBlock +import `in`.koreatech.koin.model.timetable.TimetableEvent +import java.time.LocalTime + +fun List.toTimeBlocks(): List { + val updateList = Array(10) { null } + + this.forEach { + if (it.start.hour < 9) return@forEach + + if (it.end.hour > 18) { + for (i in it.start.hour until 19) { + if (i == it.start.hour) { + updateList[i - 9] = it.convertToTimeBlock(LocalTime.of(19, 0)) + } else { + updateList[i - 9] = it.convertToEmptyTimeBlock() + } + } + } else { + val endHour: Int = if (it.end.minute == 0) it.end.hour + else it.end.hour + 1 + + for (i in it.start.hour until endHour) { + if (i == it.start.hour) { + updateList[i - 9] = it.convertToTimeBlock(it.end) + } else { + updateList[i - 9] = it.convertToEmptyTimeBlock() + } + } + } + } + + return updateList.toList() +} \ No newline at end of file diff --git a/koin/src/main/res/drawable/icon_refresh.xml b/koin/src/main/res/drawable/icon_refresh.xml new file mode 100644 index 000000000..1be9ff7ea --- /dev/null +++ b/koin/src/main/res/drawable/icon_refresh.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/koin/src/main/res/drawable/shape_timetable_row.xml b/koin/src/main/res/drawable/shape_timetable_row.xml new file mode 100644 index 000000000..501553e07 --- /dev/null +++ b/koin/src/main/res/drawable/shape_timetable_row.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/koin/src/main/res/layout/activity_timetable.xml b/koin/src/main/res/layout/activity_timetable.xml new file mode 100644 index 000000000..3d9689de1 --- /dev/null +++ b/koin/src/main/res/layout/activity_timetable.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + diff --git a/koin/src/main/res/values/colors.xml b/koin/src/main/res/values/colors.xml index 2d8eaefe1..2030ff5e9 100644 --- a/koin/src/main/res/values/colors.xml +++ b/koin/src/main/res/values/colors.xml @@ -16,4 +16,6 @@ #8E8E8E + + #BCBCBC \ No newline at end of file diff --git a/koin/src/main/res/values/strings.xml b/koin/src/main/res/values/strings.xml index 5661bc92f..7a53f46a2 100644 --- a/koin/src/main/res/values/strings.xml +++ b/koin/src/main/res/values/strings.xml @@ -148,6 +148,7 @@ 학교 메일로 비밀번호 초기화를 완료해 주세요. 이동하실래요? + 이미지 저장하기 저장되었습니다. 저장에 실패했습니다. 해당 수업을 삭제하시겠습니까? @@ -202,6 +203,7 @@ Go to Permissions to Grant Storage EXAMPLE Add widget + 코인 시간표 오류 diff --git a/koin/src/main/res/xml/timetablev2_app_widget_info.xml b/koin/src/main/res/xml/timetablev2_app_widget_info.xml new file mode 100644 index 000000000..05139ac6d --- /dev/null +++ b/koin/src/main/res/xml/timetablev2_app_widget_info.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file